From 8eb07c9355c71b1e98adc7a34f94206847537615 Mon Sep 17 00:00:00 2001 From: vasileios Date: Tue, 27 Aug 2024 16:27:19 +0200 Subject: [PATCH] [#4396] Added the attributes retrival functionality This is not the whole prefill plugin functionality. This commit handles the procedures in order to be able to send to the frontend the available attributes (for mapping the form variable with the objecttype attribute) depending on the selected objecttype's version. --- src/openapi.yaml | 58 ++++++++--- src/openforms/forms/admin/form_variable.py | 2 + .../forms/api/serializers/form_variable.py | 73 +++++++++++++- src/openforms/forms/api/typing.py | 20 ++++ .../0098_formvariable_prefill_options.py | 20 ++++ src/openforms/forms/models/form_variable.py | 5 + src/openforms/prefill/api/serializers.py | 11 --- src/openforms/prefill/api/views.py | 3 +- .../prefill/contrib/objects_api/plugin.py | 7 +- .../contrib/objects_api/tests/__init__.py | 0 ...PluginTests.test_available_attributes.yaml | 46 +++++++++ .../objects_api/tests/test_prefill_flow.py | 69 +++++++++++++ .../contrib/objects_api/tests/test_utils.py | 99 +++++++++++++++++++ .../prefill/contrib/objects_api/utils.py | 58 +++++++++++ src/openforms/variables/tests/test_views.py | 2 + 15 files changed, 444 insertions(+), 29 deletions(-) create mode 100644 src/openforms/forms/api/typing.py create mode 100644 src/openforms/forms/migrations/0098_formvariable_prefill_options.py create mode 100644 src/openforms/prefill/contrib/objects_api/tests/__init__.py create mode 100644 src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_available_attributes.yaml create mode 100644 src/openforms/prefill/contrib/objects_api/tests/test_prefill_flow.py create mode 100644 src/openforms/prefill/contrib/objects_api/tests/test_utils.py create mode 100644 src/openforms/prefill/contrib/objects_api/utils.py diff --git a/src/openapi.yaml b/src/openapi.yaml index cf2d237e60..45ad10082a 100644 --- a/src/openapi.yaml +++ b/src/openapi.yaml @@ -3692,7 +3692,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/PrefillObjectsAPIAttribute' + $ref: '#/components/schemas/PrefillAttribute' description: '' headers: X-Session-Expires-In: @@ -8537,6 +8537,8 @@ components: * `main` - Main * `authorizee` - Authorizee + prefillOptions: + $ref: '#/components/schemas/FormVariableOptions' dataType: allOf: - $ref: '#/components/schemas/DataTypeEnum' @@ -8568,6 +8570,27 @@ components: - key - name - source + FormVariableOptions: + type: object + properties: + prefillPlugin: + type: string + description: The selected prefill plugin. + objectsApiGroup: + type: integer + description: Which Objects API group to use. + objecttypeUuid: + type: string + format: uuid + title: objecttype + description: 'UUID of the objecttype in the Objecttypes API. ' + objecttypeVersion: + type: integer + description: Version of the objecttype in the Objecttypes API. + variablesMapping: + type: array + items: + $ref: '#/components/schemas/ObjecttypeVariableMapping' FormVersion: type: object properties: @@ -9121,6 +9144,26 @@ components: - namePlural - url - uuid + ObjecttypeVariableMapping: + type: object + description: A mapping between a form variable key and the corresponding Objecttype + attribute. + properties: + variableKey: + type: string + description: The 'dotted' path to a form variable key. The format should + comply to how Formio handles nested component keys. + pattern: ^(\w|\w[\w.\-]*\w)$ + targetPath: + type: array + items: + type: string + title: Segment of a JSON path + description: Representation of the JSON target location as a list of string + segments. + required: + - targetPath + - variableKey ObjecttypeVersion: type: object properties: @@ -9627,19 +9670,6 @@ components: description: |- * `main` - Main * `authorizee` - Authorizee - PrefillObjectsAPIAttribute: - type: object - properties: - value: - type: string - title: ID - description: The unique attribute identifier - label: - type: string - description: The human-readable name for an attribute. - required: - - label - - value PrefillPlugin: type: object properties: diff --git a/src/openforms/forms/admin/form_variable.py b/src/openforms/forms/admin/form_variable.py index 64674c37be..99402fa0dd 100644 --- a/src/openforms/forms/admin/form_variable.py +++ b/src/openforms/forms/admin/form_variable.py @@ -18,6 +18,7 @@ class FormVariableAdmin(admin.ModelAdmin): "prefill_plugin", "prefill_attribute", "prefill_identifier_role", + "prefill_options", "data_type", "is_sensitive_data", "initial_value", @@ -31,6 +32,7 @@ class FormVariableAdmin(admin.ModelAdmin): "prefill_plugin", "prefill_attribute", "prefill_identifier_role", + "prefill_options", "data_type", "data_format", "is_sensitive_data", diff --git a/src/openforms/forms/api/serializers/form_variable.py b/src/openforms/forms/api/serializers/form_variable.py index 27abc70275..223093c093 100644 --- a/src/openforms/forms/api/serializers/form_variable.py +++ b/src/openforms/forms/api/serializers/form_variable.py @@ -1,13 +1,21 @@ from collections import defaultdict +from django.db.models import Q from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import OpenApiExample, extend_schema_serializer from rest_framework import serializers from rest_framework.exceptions import ValidationError -from openforms.api.fields import RelatedFieldFromContext +from openforms.api.fields import ( + PrimaryKeyRelatedAsChoicesField, + RelatedFieldFromContext, +) from openforms.api.serializers import ListWithChildSerializer +from openforms.formio.api.fields import FormioVariableKeyField +from openforms.registrations.contrib.objects_api.models import ObjectsAPIGroupConfig +from openforms.utils.mixins import JsonSchemaSerializerMixin from openforms.variables.api.serializers import ServiceFetchConfigurationSerializer from openforms.variables.constants import FormVariableSources from openforms.variables.models import ServiceFetchConfiguration @@ -16,6 +24,67 @@ from ...models import Form, FormDefinition, FormVariable +@extend_schema_serializer( + examples=[ + OpenApiExample( + name="Variable mapping example", + value={ + "variable_key": "a_component_variable", + "target_path": ["path", "to.the", "target"], + }, + ) + ] +) +class ObjecttypeVariableMappingSerializer(serializers.Serializer): + """A mapping between a form variable key and the corresponding Objecttype attribute.""" + + variable_key = FormioVariableKeyField( + label=_("variable key"), + help_text=_( + "The 'dotted' path to a form variable key. The format should comply to how Formio handles nested component keys." + ), + ) + target_path = serializers.ListField( + child=serializers.CharField(label=_("Segment of a JSON path")), + label=_("target path"), + help_text=_( + "Representation of the JSON target location as a list of string segments." + ), + ) + + +class FormVariableOptionsSerializer(JsonSchemaSerializerMixin, serializers.Serializer): + prefill_plugin = serializers.CharField( + required=False, help_text=_("The selected prefill plugin.") + ) + objects_api_group = PrimaryKeyRelatedAsChoicesField( + queryset=ObjectsAPIGroupConfig.objects.exclude( + Q(objects_service=None) + | Q(objecttypes_service=None) + | Q(drc_service=None) + | Q(catalogi_service=None) + ), + label=("Objects API group"), + required=False, + help_text=_("Which Objects API group to use."), + ) + objecttype_uuid = serializers.UUIDField( + label=_("objecttype"), + required=False, + help_text=_("UUID of the objecttype in the Objecttypes API. "), + ) + objecttype_version = serializers.IntegerField( + label=_("objecttype version"), + required=False, + help_text=_("Version of the objecttype in the Objecttypes API."), + ) + variables_mapping = ObjecttypeVariableMappingSerializer( + label=_("variables mapping"), + many=True, + required=False, + ) + + class FormVariableListSerializer(ListWithChildSerializer): def get_child_serializer_class(self): return FormVariableSerializer @@ -110,6 +179,7 @@ class FormVariableSerializer(serializers.HyperlinkedModelSerializer): service_fetch_configuration = ServiceFetchConfigurationSerializer( required=False, allow_null=True ) + prefill_options = FormVariableOptionsSerializer(required=False) class Meta: model = FormVariable @@ -124,6 +194,7 @@ class Meta: "prefill_plugin", "prefill_attribute", "prefill_identifier_role", + "prefill_options", "data_type", "data_format", "is_sensitive_data", diff --git a/src/openforms/forms/api/typing.py b/src/openforms/forms/api/typing.py new file mode 100644 index 0000000000..c2de337ffa --- /dev/null +++ b/src/openforms/forms/api/typing.py @@ -0,0 +1,20 @@ +from typing import TYPE_CHECKING, Required, TypedDict + +if TYPE_CHECKING: + from openforms.registrations.contrib.objects_api.models import ObjectsAPIGroupConfig + + +class _BasePrefillOptions(TypedDict): + prefill_plugin: str + + +class ObjecttypeVariableMapping(TypedDict): + variable_key: str + target_path: list[str] + + +class ObjectsAPIPrefillOptions(_BasePrefillOptions): + objects_api_group: Required[ObjectsAPIGroupConfig] + objecttype_uuid: Required[str] + objecttype_version: Required[int] + variables_mapping: Required[list[ObjecttypeVariableMapping]] diff --git a/src/openforms/forms/migrations/0098_formvariable_prefill_options.py b/src/openforms/forms/migrations/0098_formvariable_prefill_options.py new file mode 100644 index 0000000000..35b28e3fb2 --- /dev/null +++ b/src/openforms/forms/migrations/0098_formvariable_prefill_options.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.15 on 2024-08-21 11:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forms", "0097_v267_to_v270"), + ] + + operations = [ + migrations.AddField( + model_name="formvariable", + name="prefill_options", + field=models.JSONField( + blank=True, default=dict, verbose_name="prefill options" + ), + ), + ] diff --git a/src/openforms/forms/models/form_variable.py b/src/openforms/forms/models/form_variable.py index 1aa0493cd8..e9112ba22b 100644 --- a/src/openforms/forms/models/form_variable.py +++ b/src/openforms/forms/models/form_variable.py @@ -161,6 +161,11 @@ class FormVariable(models.Model): default=IdentifierRoles.main, max_length=100, ) + prefill_options = models.JSONField( + _("prefill options"), + default=dict, + blank=True, + ) data_type = models.CharField( verbose_name=_("data type"), help_text=_("The type of the value that will be associated with this variable"), diff --git a/src/openforms/prefill/api/serializers.py b/src/openforms/prefill/api/serializers.py index 1c2ee4cff2..a5f902ca43 100644 --- a/src/openforms/prefill/api/serializers.py +++ b/src/openforms/prefill/api/serializers.py @@ -64,14 +64,3 @@ class PrefillAttributeSerializer(serializers.Serializer): label=_("Label"), help_text=_("The human-readable name for an attribute."), ) - - -class PrefillObjectsAPIAttributeSerializer(serializers.Serializer): - value = serializers.CharField( - label=_("ID"), - help_text=_("The unique attribute identifier"), - ) - label = serializers.CharField( - label=_("Label"), - help_text=_("The human-readable name for an attribute."), - ) diff --git a/src/openforms/prefill/api/views.py b/src/openforms/prefill/api/views.py index 7d0ab59af8..781e652df8 100644 --- a/src/openforms/prefill/api/views.py +++ b/src/openforms/prefill/api/views.py @@ -20,7 +20,6 @@ from .serializers import ( ChoiceWrapper, PrefillAttributeSerializer, - PrefillObjectsAPIAttributeSerializer, PrefillPluginQueryParameterSerializer, PrefillPluginSerializer, ) @@ -124,7 +123,7 @@ class PluginObjectsAPIAttributesListView(ListMixin, APIView): authentication_classes = (authentication.SessionAuthentication,) permission_classes = (permissions.IsAdminUser,) - serializer_class = PrefillObjectsAPIAttributeSerializer + serializer_class = PrefillAttributeSerializer def get_objects(self): plugin = register["objects_api"] diff --git a/src/openforms/prefill/contrib/objects_api/plugin.py b/src/openforms/prefill/contrib/objects_api/plugin.py index e56f3fb26b..ba3217a2ea 100644 --- a/src/openforms/prefill/contrib/objects_api/plugin.py +++ b/src/openforms/prefill/contrib/objects_api/plugin.py @@ -10,6 +10,7 @@ from ...base import BasePlugin from ...constants import IdentifierRoles from ...registry import register +from .utils import retrieve_properties logger = logging.getLogger(__name__) @@ -25,7 +26,8 @@ class ObjectsAPIPrefill(BasePlugin): def get_available_attributes( reference: dict[str, Any] | None = None, ) -> Iterable[tuple[str, str]]: - pass + assert reference is not None + return retrieve_properties(reference) @classmethod def get_prefill_values( @@ -41,3 +43,6 @@ def get_co_sign_values( cls, submission: Submission, identifier: str ) -> tuple[dict[str, Any], str]: pass + + def check_config(self): + pass diff --git a/src/openforms/prefill/contrib/objects_api/tests/__init__.py b/src/openforms/prefill/contrib/objects_api/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_available_attributes.yaml b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_available_attributes.yaml new file mode 100644 index 0000000000..938a6df84c --- /dev/null +++ b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_available_attributes.yaml @@ -0,0 +1,46 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 171be5abaf41e7856b423ad513df1ef8f867ff48 + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.2 + method: GET + uri: http://localhost:8001/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48/versions/3 + response: + body: + string: '{"url":"http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48/versions/3","version":3,"objectType":"http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48","status":"draft","jsonSchema":{"$id":"https://example.com/person.schema.json","type":"object","title":"Person","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"age":{"type":"integer","minimum":18},"name":{"type":"object","properties":{"last.name":{"type":"string"}}},"nested":{"type":"object","properties":{"unrelated":{"type":"string"},"submission_payment_amount":{"type":"number","multipleOf":0.01}}},"submission_date":{"type":"string","format":"date-time"},"submission_csv_url":{"type":"string","format":"uri"},"submission_pdf_url":{"type":"string","format":"uri"},"submission_payment_completed":{"type":"boolean"},"submission_payment_public_ids":{"type":"array"}}},"createdAt":"2024-02-08","modifiedAt":"2024-02-08","publishedAt":"2024-02-08"}' + headers: + Allow: + - GET, PUT, PATCH, DELETE, HEAD, OPTIONS + Connection: + - keep-alive + Content-Length: + - '985' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Wed, 28 Aug 2024 08:04:23 GMT + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +version: 1 diff --git a/src/openforms/prefill/contrib/objects_api/tests/test_prefill_flow.py b/src/openforms/prefill/contrib/objects_api/tests/test_prefill_flow.py new file mode 100644 index 0000000000..e8eb0a8d6a --- /dev/null +++ b/src/openforms/prefill/contrib/objects_api/tests/test_prefill_flow.py @@ -0,0 +1,69 @@ +from pathlib import Path +from unittest.mock import patch + +from django.test import TestCase + +from openforms.registrations.contrib.objects_api.models import ObjectsAPIConfig +from openforms.registrations.contrib.objects_api.tests.factories import ( + ObjectsAPIGroupConfigFactory, +) +from openforms.utils.tests.vcr import OFVCRMixin + +from ..plugin import PLUGIN_IDENTIFIER, ObjectsAPIPrefill + +VCR_TEST_FILES = Path(__file__).parent / "files" + + +class ObjectsAPIPrefillPluginTests(OFVCRMixin, TestCase): + """This test case requires the Objects & Objecttypes API to be running. + + See the relevant Docker compose in the ``docker/`` folder. + """ + + VCR_TEST_FILES = VCR_TEST_FILES + + def setUp(self): + super().setUp() + + config_patcher = patch( + "openforms.registrations.contrib.objects_api.models.ObjectsAPIConfig.get_solo", + return_value=ObjectsAPIConfig(), + ) + self.mock_get_config = config_patcher.start() + self.addCleanup(config_patcher.stop) + + self.objects_api_group = ObjectsAPIGroupConfigFactory.create( + for_test_docker_compose=True + ) + + def test_available_attributes(self): + plugin = ObjectsAPIPrefill(PLUGIN_IDENTIFIER) + + # Trigger the attributes retrieval flow + available_attrs = plugin.get_available_attributes( + reference={ + "objects_api_group": self.objects_api_group, + "objects_api_objecttype_uuid": "8e46e0a5-b1b4-449b-b9e9-fa3cea655f48", + "objects_api_objecttype_version": "3", + } + ) + + self.assertEqual( + available_attrs, + [ + ("age", "age (integer)"), + ("name.last.name", "name > last > name (string)"), + ("nested.unrelated", "nested > unrelated (string)"), + ( + "nested.submission_payment_amount", + "nested > submission_payment_amount (number)", + ), + ("submission_date", "submission_date (string)"), + ("submission_csv_url", "submission_csv_url (string)"), + ("submission_pdf_url", "submission_pdf_url (string)"), + ( + "submission_payment_completed", + "submission_payment_completed (boolean)", + ), + ], + ) diff --git a/src/openforms/prefill/contrib/objects_api/tests/test_utils.py b/src/openforms/prefill/contrib/objects_api/tests/test_utils.py new file mode 100644 index 0000000000..9d47b67fb8 --- /dev/null +++ b/src/openforms/prefill/contrib/objects_api/tests/test_utils.py @@ -0,0 +1,99 @@ +from django.test import SimpleTestCase + +from ..utils import parse_schema_properties + +SCHEMA_WITH_NESTED_PROPERTIES = { + "$id": "https://example.com/person.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "id": {"type": "string"}, + "invalid": {}, + "name": {"type": "string"}, + "address": { + "type": "object", + "properties": { + "street": { + "type": "string", + }, + "city": {"type": "string"}, + "zipCode": {"type": "string"}, + }, + "required": ["street", "city", "zipCode"], + }, + "contacts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + }, + "value": {"type": "string"}, + }, + "required": ["type", "value"], + }, + }, + }, + "required": ["id", "name", "address", "contacts"], +} + + +SCHEMA_STR_TYPE = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "string", + "minLength": 5, + "maxLength": 100, + "pattern": "^[a-zA-Z0-9_-]*$", + "format": "date-time", + "examples": ["2024-08-27T14:00:00Z", "user-123"], +} + +SCHEMA_ARRAY_TYPE = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": { + "type": "array", + "items": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}], + }, +} + +SCHEMA_NO_TYPE = { + "$id": "https://example.com/person.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "name": {"type": "string"}, + }, +} + + +class SchemaParsingTests(SimpleTestCase): + def test_simple_schema(self): + result = parse_schema_properties(SCHEMA_WITH_NESTED_PROPERTIES) + self.assertEqual( + result, + [ + ("id", "string"), + ("invalid", "unknown"), + ("name", "string"), + ("address.street", "string"), + ("address.city", "string"), + ("address.zipCode", "string"), + ("contacts.type", "string"), + ("contacts.value", "string"), + ], + ) + + def test_schema_str_type(self): + result = parse_schema_properties(SCHEMA_STR_TYPE) + self.assertEqual(result, [("string", "string")]) + + def test_schema_array_type(self): + result = parse_schema_properties(SCHEMA_ARRAY_TYPE) + self.assertEqual( + result, [("[0]", "string"), ("[1]", "number"), ("[2]", "boolean")] + ) + + def test_schema_no_type(self): + result = parse_schema_properties(SCHEMA_NO_TYPE) + self.assertEqual(result, []) diff --git a/src/openforms/prefill/contrib/objects_api/utils.py b/src/openforms/prefill/contrib/objects_api/utils.py new file mode 100644 index 0000000000..1139e03a7b --- /dev/null +++ b/src/openforms/prefill/contrib/objects_api/utils.py @@ -0,0 +1,58 @@ +from typing import Any, Iterable + +from referencing.jsonschema import ObjectSchema + +from openforms.registrations.contrib.objects_api.client import get_objecttypes_client + + +def parse_schema_properties( + schema: ObjectSchema, parent_key: str = "" +) -> list[tuple[str, str]]: + properties = [] + + if not schema.get("type"): + return [] + + if schema["type"] == "object": + for prop, prop_schema in schema.get("properties", {}).items(): + full_key = f"{parent_key}.{prop}" if parent_key else prop + prop_type = prop_schema.get("type", "unknown") + properties.append((full_key, prop_type)) + if prop_type == "object" or ( + prop_type == "array" and "items" in prop_schema + ): + properties.extend(parse_schema_properties(prop_schema, full_key)) + elif schema["type"] == "array": + items_schema = schema.get("items", {}) + if isinstance(items_schema, dict): + properties.extend(parse_schema_properties(items_schema, f"{parent_key}")) + elif isinstance(items_schema, list): + for i, item_schema in enumerate(items_schema): + properties.extend( + parse_schema_properties(item_schema, f"{parent_key}[{i}]") + ) + else: + properties.append((parent_key or schema["type"], schema["type"])) + + # also remove the props of type object since it's not needed e.g.-> (name, object) + # it's the parent prop of the object, we need the real properties and their types + return [ + (prop[0], prop[1]) for prop in properties if prop[1] not in ("object", "array") + ] + + +def retrieve_properties( + reference: dict[str, Any] | None = None, +) -> Iterable[tuple[str, str]]: + assert reference is not None + + with get_objecttypes_client(reference["objects_api_group"]) as client: + json_schema = client.get_objecttype_version( + reference["objects_api_objecttype_uuid"], + reference["objects_api_objecttype_version"], + )["jsonSchema"] + + properties = parse_schema_properties(json_schema) + return [ + (prop[0], f"{prop[0].replace('.', ' > ')} ({prop[1]})") for prop in properties + ] diff --git a/src/openforms/variables/tests/test_views.py b/src/openforms/variables/tests/test_views.py index 3168aceb3f..368f582669 100644 --- a/src/openforms/variables/tests/test_views.py +++ b/src/openforms/variables/tests/test_views.py @@ -80,6 +80,7 @@ def get_initial_value(self, *args, **kwargs): "prefill_plugin": "", "prefill_attribute": "", "prefill_identifier_role": IdentifierRoles.main, + "prefill_options": {}, "data_type": FormVariableDataTypes.datetime, "data_format": "", "is_sensitive_data": False, @@ -169,6 +170,7 @@ def get_variables(self) -> list[FormVariable]: "prefill_plugin": "", "prefill_attribute": "", "prefill_identifier_role": IdentifierRoles.main, + "prefill_options": {}, "data_type": FormVariableDataTypes.string, "data_format": "", "is_sensitive_data": False,