diff --git a/pyright.pyproject.toml b/pyright.pyproject.toml index e69115a161..79555fabcf 100644 --- a/pyright.pyproject.toml +++ b/pyright.pyproject.toml @@ -36,8 +36,7 @@ include = [ "src/openforms/emails/templatetags/cosign_information.py", # Registrations "src/openforms/registrations/tasks.py", - "src/openforms/registrations/contrib/email/config.py", - "src/openforms/registrations/contrib/email/plugin.py", + "src/openforms/registrations/contrib/email/", "src/openforms/registrations/contrib/stuf_zds/options.py", "src/openforms/registrations/contrib/stuf_zds/plugin.py", "src/openforms/registrations/contrib/stuf_zds/typing.py", diff --git a/src/openforms/js/compiled-lang/en.json b/src/openforms/js/compiled-lang/en.json index b09003fddf..e95e181f36 100644 --- a/src/openforms/js/compiled-lang/en.json +++ b/src/openforms/js/compiled-lang/en.json @@ -1273,6 +1273,12 @@ "value": "Minimum value" } ], + "BHr/3M": [ + { + "type": 0, + "value": "If specified, the recipient email addresses will be taken from the selected variable. You must still specify 'regular' email addresses as a fallback, in case something is wrong with the variable." + } + ], "BKTOtD": [ { "offset": 0, @@ -1873,6 +1879,12 @@ "value": "The co-sign component requires at least one authentication plugin to be enabled." } ], + "FNT8nq": [ + { + "type": 0, + "value": "Variable containing email addresses" + } + ], "FOlQaP": [ { "type": 0, @@ -2233,6 +2245,12 @@ "value": " is unknown. We can only display the JSON definition." } ], + "Igt0Rc": [ + { + "type": 0, + "value": "Skip ownership check" + } + ], "IhIqdj": [ { "type": 0, @@ -3251,6 +3269,12 @@ "value": "Open editor" } ], + "T2TGaF": [ + { + "type": 0, + "value": "Ownership checks" + } + ], "T2dCIS": [ { "type": 0, @@ -5571,6 +5595,12 @@ "value": "The registration result will be an object from the selected type." } ], + "nhBYT3": [ + { + "type": 0, + "value": "Recipients" + } + ], "nkr7r0": [ { "type": 0, @@ -5889,6 +5919,12 @@ "value": "How errored submissions of this form will be removed after the limit. Leave blank to use value in General Configuration." } ], + "qdV9zh": [ + { + "type": 0, + "value": "If enabled, then no access control on the referenced object is performed. Ensure that it does not contain private data before checking this!" + } + ], "qmBAmr": [ { "type": 0, @@ -6297,6 +6333,12 @@ "value": "Value" } ], + "vaO0I+": [ + { + "type": 0, + "value": "Content" + } + ], "ve3rsH": [ { "type": 0, diff --git a/src/openforms/js/compiled-lang/nl.json b/src/openforms/js/compiled-lang/nl.json index 6e9966d2f5..589bfa700a 100644 --- a/src/openforms/js/compiled-lang/nl.json +++ b/src/openforms/js/compiled-lang/nl.json @@ -1277,6 +1277,12 @@ "value": "Minimale waarde" } ], + "BHr/3M": [ + { + "type": 0, + "value": "Als een variable geselecteerd is, dan wordt de waarde gebruikt als e-mailadres(sen) van de ontvangers. Je moet nog steeds een vast adres opgeven als terugvaloptie voor het geval er iets fout gaat met de variabele." + } + ], "BKTOtD": [ { "offset": 0, @@ -1894,6 +1900,12 @@ "value": "Er moet minstens één authenticatiemethode ingeschakeld zijn voor de mede-ondertekencomponent." } ], + "FNT8nq": [ + { + "type": 0, + "value": "Variabele die adres(sen) bevat" + } + ], "FOlQaP": [ { "type": 0, @@ -2254,6 +2266,12 @@ "value": " is niet bekend. We kunnen enkel de JSON-definitie weergeven." } ], + "Igt0Rc": [ + { + "type": 0, + "value": "Sla eigenaarcontrole over" + } + ], "IhIqdj": [ { "type": 0, @@ -2597,7 +2615,7 @@ "LeVpdf": [ { "type": 0, - "value": "Plugin-insellingen: e-mail" + "value": "Plugin-instellingen: e-mail" } ], "LiXrER": [ @@ -3264,6 +3282,12 @@ "value": "Editor openen" } ], + "T2TGaF": [ + { + "type": 0, + "value": "Eigenaarcontroles" + } + ], "T2dCIS": [ { "type": 0, @@ -5589,6 +5613,12 @@ "value": "Het registratieresultaat zal een object van dit type zijn." } ], + "nhBYT3": [ + { + "type": 0, + "value": "Ontvangers" + } + ], "nkr7r0": [ { "type": 0, @@ -5907,6 +5937,12 @@ "value": "Geeft aan hoe niet voltooide inzendingen (door fouten in de afhandeling) worden opgeschoond na de bewaartermijn. Laat leeg om de waarde van de algemene configuratie te gebruiken." } ], + "qdV9zh": [ + { + "type": 0, + "value": "Indien aangevinkt, dan worden geen controles uitgevoerd of de (ingelogde) gebruiker toegang heeft op het gerefereerde object. Valideer dat de objecten geen privacy-gevoelige gegevens bevatten voor je dit aanvinkt!" + } + ], "qmBAmr": [ { "type": 0, @@ -6315,6 +6351,12 @@ "value": "(Standaard)tekst" } ], + "vaO0I+": [ + { + "type": 0, + "value": "E-mailinhoud" + } + ], "ve3rsH": [ { "type": 0, diff --git a/src/openforms/js/components/admin/form_design/RegistrationFields.stories.js b/src/openforms/js/components/admin/form_design/RegistrationFields.stories.js index a35a3378e5..86fbd85795 100644 --- a/src/openforms/js/components/admin/form_design/RegistrationFields.stories.js +++ b/src/openforms/js/components/admin/form_design/RegistrationFields.stories.js @@ -403,6 +403,73 @@ export default { label: 'textfield2', }, }, + availableFormVariables: [ + { + dataFormat: '', + dataType: 'string', + form: 'http://localhost:8000/api/v2/forms/ae26e20c-f059-4fdf-bb82-afc377869bb5', + formDefinition: null, + initialValue: '', + isSensitiveData: false, + key: 'textField1', + name: 'textfield1', + prefillAttribute: '', + prefillPlugin: '', + source: 'component', + }, + { + dataFormat: '', + dataType: 'string', + form: 'http://localhost:8000/api/v2/forms/ae26e20c-f059-4fdf-bb82-afc377869bb5', + formDefinition: null, + initialValue: '', + isSensitiveData: false, + key: 'textField2', + name: 'textfield2', + prefillAttribute: '', + prefillPlugin: '', + source: 'component', + }, + { + dataFormat: '', + dataType: 'string', + form: 'http://localhost:8000/api/v2/forms/ae26e20c-f059-4fdf-bb82-afc377869bb5', + formDefinition: null, + initialValue: '', + isSensitiveData: false, + key: 'userDefinedVar1', + name: 'User defined string', + prefillAttribute: '', + prefillPlugin: '', + source: 'user_defined', + }, + { + dataFormat: '', + dataType: 'array', + form: 'http://localhost:8000/api/v2/forms/ae26e20c-f059-4fdf-bb82-afc377869bb5', + formDefinition: null, + initialValue: [], + isSensitiveData: false, + key: 'userDefinedVar2', + name: 'User defined array', + prefillAttribute: '', + prefillPlugin: '', + source: 'user_defined', + }, + { + dataFormat: '', + dataType: 'float', + form: 'http://localhost:8000/api/v2/forms/ae26e20c-f059-4fdf-bb82-afc377869bb5', + formDefinition: null, + initialValue: null, + isSensitiveData: false, + key: 'userDefinedVar3', + name: 'User defined float', + prefillAttribute: '', + prefillPlugin: '', + source: 'user_defined', + }, + ], registrationPluginsVariables: [ { pluginIdentifier: 'stuf-zds-create-zaak', diff --git a/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsForm.js b/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsForm.js index fb6a8d8608..ae75d34e5b 100644 --- a/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsForm.js +++ b/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsForm.js @@ -23,6 +23,7 @@ const EmailOptionsForm = ({name, label, schema, formData, onChange}) => { /> } initialFormData={{ + toEmailsFromVariable: '', // ensure an initial value is provided ...formData, // ensure we have a blank row initially toEmails: formData.toEmails?.length ? formData.toEmails : [''], @@ -51,6 +52,7 @@ EmailOptionsForm.propTypes = { emailSubject: PropTypes.string, paymentEmails: PropTypes.arrayOf(PropTypes.string), toEmails: PropTypes.arrayOf(PropTypes.string), + toEmailsFromVariable: PropTypes.string, }), onChange: PropTypes.func.isRequired, }; diff --git a/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsFormFields.js b/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsFormFields.js index ae83aadfbd..19c7bcae38 100644 --- a/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsFormFields.js +++ b/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsFormFields.js @@ -17,6 +17,7 @@ import EmailHasAttachmentSelect from './fields/EmailHasAttachmentSelect'; import EmailPaymentSubject from './fields/EmailPaymentSubject'; import EmailPaymentUpdateRecipients from './fields/EmailPaymentUpdateRecipients'; import EmailRecipients from './fields/EmailRecipients'; +import EmailRecipientsFromVariable from './fields/EmailRecipientsFromVariable'; import EmailSubject from './fields/EmailSubject'; const EmailOptionsFormFields = ({name, schema}) => { @@ -35,8 +36,26 @@ const EmailOptionsFormFields = ({name, schema}) => { const relevantErrors = filterErrors(name, validationErrors); return ( -
+
+ } + > + +
+ +
+ } + > diff --git a/src/openforms/js/components/admin/form_design/registrations/email/fields/EmailRecipients.js b/src/openforms/js/components/admin/form_design/registrations/email/fields/EmailRecipients.js index 7c2ad6c6cf..ff7fcc77ee 100644 --- a/src/openforms/js/components/admin/form_design/registrations/email/fields/EmailRecipients.js +++ b/src/openforms/js/components/admin/form_design/registrations/email/fields/EmailRecipients.js @@ -19,6 +19,7 @@ const EmailRecipients = () => { defaultMessage="The email addresses to which the submission details will be sent" /> } + required > { + const [fieldProps, , {setValue}] = useField('toEmailsFromVariable'); + return ( + + + } + helpText={ + + } + > + { + const newValue = event.target.value; + setValue(newValue == null ? '' : newValue); + }} + filter={variable => ['string', 'array'].includes(variable.dataType)} + /> + + + ); +}; + +EmailRecipientsFromVariable.propTypes = {}; + +export default EmailRecipientsFromVariable; diff --git a/src/openforms/js/components/admin/form_design/variables/prefill/PrefillSummary.js b/src/openforms/js/components/admin/form_design/variables/prefill/PrefillSummary.js index a6265f56b4..6d072ea52d 100644 --- a/src/openforms/js/components/admin/form_design/variables/prefill/PrefillSummary.js +++ b/src/openforms/js/components/admin/form_design/variables/prefill/PrefillSummary.js @@ -94,6 +94,8 @@ const PrefillSummary = ({ } isOpen={modalOpen} closeModal={() => setModalOpen(false)} + // FIXME: push this down to the plugin-specific components, somehow + extraModifiers={plugin === 'objects_api' ? ['large'] : undefined} > { const {values, setFieldValue, setValues} = useFormikContext(); const { plugin, - options: {objecttypeUuid, objecttypeVersion, objectsApiGroup}, + options: {objecttypeUuid, objecttypeVersion, objectsApiGroup, skipOwnershipCheck}, } = values; const {showCopyButton, toggleShowCopyButton} = useStatus(); @@ -90,6 +91,7 @@ const ObjectsAPIFields = () => { objectsApiGroup: null, objecttypeUuid: '', objecttypeVersion: null, + skipOwnershipCheck: false, authAttributePath: undefined, variablesMapping: [], }; @@ -235,13 +237,26 @@ const ObjectsAPIFields = () => { objectTypeFieldName="options.objecttypeUuid" /> - +
+ +
+ } + > + + {!skipOwnershipCheck && ( + + )}
{ + const [fieldProps] = useField({name: 'options.skipOwnershipCheck', type: 'checkbox'}); + return ( + + + } + helpText={ + + } + {...fieldProps} + /> + + ); +}; + +SkipOwnershipCheck.propTypes = {}; + +export default SkipOwnershipCheck; diff --git a/src/openforms/js/lang/en.json b/src/openforms/js/lang/en.json index 9ec99111ef..07ff822d2b 100644 --- a/src/openforms/js/lang/en.json +++ b/src/openforms/js/lang/en.json @@ -559,6 +559,11 @@ "description": "Email registration options 'emailPaymentSubject' label", "originalDefault": "Email payment subject" }, + "BHr/3M": { + "defaultMessage": "If specified, the recipient email addresses will be taken from the selected variable. You must still specify 'regular' email addresses as a fallback, in case something is wrong with the variable.", + "description": "Email registration options 'toEmailsFromVariable' helpText", + "originalDefault": "If specified, the recipient email addresses will be taken from the selected variable. You must still specify 'regular' email addresses as a fallback, in case something is wrong with the variable." + }, "BKTOtD": { "defaultMessage": "{varCount, plural, =0 {} one {(1 variable mapped)} other {({varCount} variables mapped)} }", "description": "Managed Camunda process vars state feedback", @@ -814,6 +819,11 @@ "description": "MissingAuthCosignWarning message", "originalDefault": "The co-sign component requires at least one authentication plugin to be enabled." }, + "FNT8nq": { + "defaultMessage": "Variable containing email addresses", + "description": "Email registration options 'toEmailsFromVariable' label", + "originalDefault": "Variable containing email addresses" + }, "FQ1JZc": { "defaultMessage": "Zaaktype code for newly created Zaken in StUF-ZDS", "description": "StUF-ZDS registration options 'zdsZaaktypeCode' helpText", @@ -1034,6 +1044,11 @@ "description": "Objects API prefill mappings fieldset title", "originalDefault": "Mappings" }, + "Igt0Rc": { + "defaultMessage": "Skip ownership check", + "description": "Objects API registration: skipOwnershipCheck label", + "originalDefault": "Skip ownership check" + }, "IhIqdj": { "defaultMessage": "Decision definition ID", "description": "Decision definition ID label", @@ -1564,6 +1579,11 @@ "description": "Text on button to open design token editor in modal.", "originalDefault": "Open editor" }, + "T2TGaF": { + "defaultMessage": "Ownership checks", + "description": "Objects API ownership check fieldset title", + "originalDefault": "Ownership checks" + }, "TB/ku3": { "defaultMessage": "Add variable", "description": "Add user defined variable button label", @@ -2579,6 +2599,11 @@ "description": "Objects API registration options 'Objecttype' helpText", "originalDefault": "The registration result will be an object from the selected type." }, + "nhBYT3": { + "defaultMessage": "Recipients", + "description": "Email registration: recipients fieldset title", + "originalDefault": "Recipients" + }, "nkr7r0": { "defaultMessage": "Amount of days when all submissions of this form will be permanently deleted. Leave blank to use value in General Configuration.", "description": "All Submissions Removal Limit help text", @@ -2724,6 +2749,11 @@ "description": "Errored Submissions Removal Method help text", "originalDefault": "How errored submissions of this form will be removed after the limit. Leave blank to use value in General Configuration." }, + "qdV9zh": { + "defaultMessage": "If enabled, then no access control on the referenced object is performed. Ensure that it does not contain private data before checking this!", + "description": "Objects API registration: skipOwnershipCheck helpText", + "originalDefault": "If enabled, then no access control on the referenced object is performed. Ensure that it does not contain private data before checking this!" + }, "qo1UbS": { "defaultMessage": "Export", "description": "Export form button", @@ -2929,6 +2959,11 @@ "description": "Create form definition tile", "originalDefault": "Create a new form definition" }, + "vaO0I+": { + "defaultMessage": "Content", + "description": "Email registration: content fieldset title", + "originalDefault": "Content" + }, "viEfGU": { "defaultMessage": "The content of the submission confirmation page. It can contain variables that will be templated from the submitted form data. If not specified, the global template will be used.", "description": "Confirmation template help text", diff --git a/src/openforms/js/lang/nl.json b/src/openforms/js/lang/nl.json index 28136b3ddd..9341ee7a7b 100644 --- a/src/openforms/js/lang/nl.json +++ b/src/openforms/js/lang/nl.json @@ -564,6 +564,11 @@ "description": "Email registration options 'emailPaymentSubject' label", "originalDefault": "Email payment subject" }, + "BHr/3M": { + "defaultMessage": "Als een variable geselecteerd is, dan wordt de waarde gebruikt als e-mailadres(sen) van de ontvangers. Je moet nog steeds een vast adres opgeven als terugvaloptie voor het geval er iets fout gaat met de variabele.", + "description": "Email registration options 'toEmailsFromVariable' helpText", + "originalDefault": "If specified, the recipient email addresses will be taken from the selected variable. You must still specify 'regular' email addresses as a fallback, in case something is wrong with the variable." + }, "BKTOtD": { "defaultMessage": "{varCount, plural, =0 {} one {(1 variabele gekoppeld)} other {({varCount} variabelen gekoppeld)} }", "description": "Managed Camunda process vars state feedback", @@ -823,6 +828,11 @@ "description": "MissingAuthCosignWarning message", "originalDefault": "The co-sign component requires at least one authentication plugin to be enabled." }, + "FNT8nq": { + "defaultMessage": "Variabele die adres(sen) bevat", + "description": "Email registration options 'toEmailsFromVariable' label", + "originalDefault": "Variable containing email addresses" + }, "FQ1JZc": { "defaultMessage": "Zaaktypecode waarmee de nieuwe zaak aangemaakt wordt.", "description": "StUF-ZDS registration options 'zdsZaaktypeCode' helpText", @@ -1043,6 +1053,11 @@ "description": "Objects API prefill mappings fieldset title", "originalDefault": "Mappings" }, + "Igt0Rc": { + "defaultMessage": "Sla eigenaarcontrole over", + "description": "Objects API registration: skipOwnershipCheck label", + "originalDefault": "Skip ownership check" + }, "IhIqdj": { "defaultMessage": "Beslisdefinitie-ID", "description": "Decision definition ID label", @@ -1229,7 +1244,7 @@ "originalDefault": "Something went wrong while retrieving the available products defined in the selected case. Please check that the services in the selected API group are configured correctly." }, "LeVpdf": { - "defaultMessage": "Plugin-insellingen: e-mail", + "defaultMessage": "Plugin-instellingen: e-mail", "description": "Email registration options modal title", "originalDefault": "Plugin configuration: Email" }, @@ -1579,6 +1594,11 @@ "description": "Text on button to open design token editor in modal.", "originalDefault": "Open editor" }, + "T2TGaF": { + "defaultMessage": "Eigenaarcontroles", + "description": "Objects API ownership check fieldset title", + "originalDefault": "Ownership checks" + }, "TB/ku3": { "defaultMessage": "Variabele toevoegen", "description": "Add user defined variable button label", @@ -2600,6 +2620,11 @@ "description": "Objects API registration options 'Objecttype' helpText", "originalDefault": "The registration result will be an object from the selected type." }, + "nhBYT3": { + "defaultMessage": "Ontvangers", + "description": "Email registration: recipients fieldset title", + "originalDefault": "Recipients" + }, "nkr7r0": { "defaultMessage": "Aantal dagen dat een inzending bewaard blijft voordat deze definitief verwijderd wordt. Laat leeg om de waarde van de algemene configuratie te gebruiken.", "description": "All Submissions Removal Limit help text", @@ -2745,6 +2770,11 @@ "description": "Errored Submissions Removal Method help text", "originalDefault": "How errored submissions of this form will be removed after the limit. Leave blank to use value in General Configuration." }, + "qdV9zh": { + "defaultMessage": "Indien aangevinkt, dan worden geen controles uitgevoerd of de (ingelogde) gebruiker toegang heeft op het gerefereerde object. Valideer dat de objecten geen privacy-gevoelige gegevens bevatten voor je dit aanvinkt!", + "description": "Objects API registration: skipOwnershipCheck helpText", + "originalDefault": "If enabled, then no access control on the referenced object is performed. Ensure that it does not contain private data before checking this!" + }, "qo1UbS": { "defaultMessage": "Exporteren", "description": "Export form button", @@ -2950,6 +2980,11 @@ "description": "Create form definition tile", "originalDefault": "Create a new form definition" }, + "vaO0I+": { + "defaultMessage": "E-mailinhoud", + "description": "Email registration: content fieldset title", + "originalDefault": "Content" + }, "viEfGU": { "defaultMessage": "De inhoud van bevestigingspagina na het versturen van de inzending. Dit sjabloon mag variabelen bevatten met inzendings-gegevens. Laat dit veld leeg om de standaardinstelling te gebruiken.", "description": "Confirmation template help text", diff --git a/src/openforms/prefill/contrib/objects_api/api/serializers.py b/src/openforms/prefill/contrib/objects_api/api/serializers.py index 5470fdb307..6195409730 100644 --- a/src/openforms/prefill/contrib/objects_api/api/serializers.py +++ b/src/openforms/prefill/contrib/objects_api/api/serializers.py @@ -8,6 +8,8 @@ from openforms.formio.api.fields import FormioVariableKeyField from openforms.utils.mixins import JsonSchemaSerializerMixin +from ..typing import ObjectsAPIOptions + class PrefillTargetPathsSerializer(serializers.Serializer): target_path = serializers.ListField( @@ -63,6 +65,14 @@ class ObjectsAPIOptionsSerializer(JsonSchemaSerializerMixin, serializers.Seriali required=True, help_text=_("Version of the objecttype in the Objecttypes API."), ) + skip_ownership_check = serializers.BooleanField( + label=_("skip ownership check"), + help_text=_( + "If enabled, no authentication/ownership checks of the referenced object " + "are performed." + ), + default=False, + ) auth_attribute_path = serializers.ListField( child=serializers.CharField(label=_("Segment of a JSON path")), label=_("Path to auth attribute (e.g. BSN/KVK) in objects"), @@ -70,11 +80,28 @@ class ObjectsAPIOptionsSerializer(JsonSchemaSerializerMixin, serializers.Seriali "This is used to perform validation to verify that the authenticated " "user is the owner of the object." ), - allow_empty=False, - required=True, + required=False, + default=list, + allow_empty=True, ) variables_mapping = ObjecttypeVariableMappingSerializer( label=_("variables mapping"), many=True, required=True, ) + + def validate(self, attrs: ObjectsAPIOptions) -> ObjectsAPIOptions: + # ensure that an auth_attribute_path is specified when ownership checks are + # not skipped. + if not attrs["skip_ownership_check"] and not attrs["auth_attribute_path"]: + raise serializers.ValidationError( + { + "auth_attribute_path": _( + "You must specify which attribute to compare the authenticated " + "user identifier with." + ), + }, + code="empty", + ) + + return attrs diff --git a/src/openforms/prefill/contrib/objects_api/plugin.py b/src/openforms/prefill/contrib/objects_api/plugin.py index ee710fd688..102701afe3 100644 --- a/src/openforms/prefill/contrib/objects_api/plugin.py +++ b/src/openforms/prefill/contrib/objects_api/plugin.py @@ -31,6 +31,10 @@ class ObjectsAPIPrefill(BasePlugin[ObjectsAPIOptions]): def verify_initial_data_ownership( self, submission: Submission, prefill_options: ObjectsAPIOptions ) -> None: + if prefill_options["skip_ownership_check"]: + logger.info("Skipping ownership check for submission %r.", submission.uuid) + return + assert submission.initial_data_reference api_group = prefill_options["objects_api_group"] assert api_group, "Can't do anything useful without an API group" diff --git a/src/openforms/prefill/contrib/objects_api/tests/test_initial_data_ownership_validation.py b/src/openforms/prefill/contrib/objects_api/tests/test_initial_data_ownership_validation.py index c3dbc7b227..b2b48fb087 100644 --- a/src/openforms/prefill/contrib/objects_api/tests/test_initial_data_ownership_validation.py +++ b/src/openforms/prefill/contrib/objects_api/tests/test_initial_data_ownership_validation.py @@ -159,3 +159,19 @@ def test_verify_initial_data_ownership_missing_auth_attribute_path_causes_failin logs.filter_event("object_ownership_check_success").exists() ) self.assertFalse(logs.filter_event("prefill_retrieve_success").exists()) + + def test_allow_prefill_when_ownership_check_is_skipped(self): + self.variable.prefill_options["skip_ownership_check"] = True + self.variable.save() + submission = SubmissionFactory.create( + form=self.form, + # invalid BSN + auth_info__value="000XXX000", + auth_info__attribute=AuthAttribute.bsn, + initial_data_reference=self.object_ref, + ) + + try: + prefill_variables(submission=submission) + except PermissionDenied as exc: + raise self.failureException("Ownership check should be skipped") from exc diff --git a/src/openforms/prefill/contrib/objects_api/tests/test_options_validation.py b/src/openforms/prefill/contrib/objects_api/tests/test_options_validation.py new file mode 100644 index 0000000000..0469c35aa0 --- /dev/null +++ b/src/openforms/prefill/contrib/objects_api/tests/test_options_validation.py @@ -0,0 +1,47 @@ +import uuid + +from rest_framework.test import APITestCase + +from openforms.contrib.objects_api.tests.factories import ObjectsAPIGroupConfigFactory + +from ..api.serializers import ObjectsAPIOptionsSerializer + + +class OptionValidationTests(APITestCase): + """ + Test the serializer used for options validation. + """ + + def test_auth_attribute_not_required(self): + api_group = ObjectsAPIGroupConfigFactory.create() + data = { + "objects_api_group": api_group.pk, + "objecttype_uuid": uuid.uuid4(), + "objecttype_version": 3, + "skip_ownership_check": True, + "auth_attribute_path": [], + "variables_mapping": [], + } + serializer = ObjectsAPIOptionsSerializer(data=data) + + is_valid = serializer.is_valid() + + self.assertTrue(is_valid) + + def test_auth_attribute_required(self): + api_group = ObjectsAPIGroupConfigFactory.create() + data = { + "objects_api_group": api_group.pk, + "objecttype_uuid": uuid.uuid4(), + "objecttype_version": 3, + "skip_ownership_check": False, + "auth_attribute_path": [], + "variables_mapping": [], + } + serializer = ObjectsAPIOptionsSerializer(data=data) + + is_valid = serializer.is_valid() + + self.assertFalse(is_valid) + error = serializer.errors["auth_attribute_path"][0] + self.assertEqual(error.code, "empty") diff --git a/src/openforms/prefill/contrib/objects_api/typing.py b/src/openforms/prefill/contrib/objects_api/typing.py index 36fb5e7e0e..4b5ee90a58 100644 --- a/src/openforms/prefill/contrib/objects_api/typing.py +++ b/src/openforms/prefill/contrib/objects_api/typing.py @@ -13,5 +13,6 @@ class ObjectsAPIOptions(TypedDict): objects_api_group: ObjectsAPIGroupConfig objecttype_uuid: UUID objecttype_version: int + skip_ownership_check: bool auth_attribute_path: list[str] variables_mapping: list[VariableMapping] diff --git a/src/openforms/registrations/contrib/email/config.py b/src/openforms/registrations/contrib/email/config.py index 7cda616e80..2fb4ca3a5a 100644 --- a/src/openforms/registrations/contrib/email/config.py +++ b/src/openforms/registrations/contrib/email/config.py @@ -7,18 +7,50 @@ from openforms.api.validators import AllOrNoneTruthyFieldsValidator from openforms.emails.validators import URLSanitationValidator +from openforms.formio.api.fields import FormioVariableKeyField from openforms.template.validators import DjangoTemplateValidator from openforms.utils.mixins import JsonSchemaSerializerMixin from .constants import AttachmentFormat +class Options(TypedDict): + """ + Shape of the email registration plugin options. + + This describes the shape of :attr:`EmailOptionsSerializer.validated_data`, after + the input data has been cleaned/validated. + """ + + to_emails: list[str] + to_emails_from_variable: NotRequired[str] + attachment_formats: NotRequired[list[AttachmentFormat | str]] + payment_emails: NotRequired[list[str]] + attach_files_to_email: bool | None + email_subject: NotRequired[str] + email_payment_subject: NotRequired[str] + email_content_template_html: NotRequired[str] + email_content_template_text: NotRequired[str] + + class EmailOptionsSerializer(JsonSchemaSerializerMixin, serializers.Serializer): to_emails = serializers.ListField( child=serializers.EmailField(), label=_("The email addresses to which the submission details will be sent"), + # always required, even if using to_emails_from_variable because that may contain + # bad data/unexpectedly be empty due to reasons (failing prefill, for example) required=True, ) + to_emails_from_variable = FormioVariableKeyField( + label=_("Key of the target variable containing the email address"), + required=False, + allow_blank=True, + help_text=_( + "Key of the target variable whose value will be used for the mailing. " + "When using this field, the mailing will only be sent to this email address. " + "The email addresses field would then be ignored. " + ), + ) attachment_formats = serializers.ListField( child=serializers.ChoiceField(choices=AttachmentFormat.choices), label=_("The format(s) of the attachment(s) containing the submission details"), @@ -100,24 +132,6 @@ class Meta: ] -class Options(TypedDict): - """ - Shape of the email registration plugin options. - - This describes the shape of :attr:`EmailOptionsSerializer.validated_data`, after - the input data has been cleaned/validated. - """ - - to_emails: list[str] - attachment_formats: NotRequired[list[AttachmentFormat | str]] - payment_emails: NotRequired[list[str]] - attach_files_to_email: bool | None - email_subject: NotRequired[str] - email_payment_subject: NotRequired[str] - email_content_template_html: NotRequired[str] - email_content_template_text: NotRequired[str] - - # sanity check for development - keep serializer and type definitions in sync _serializer_fields = EmailOptionsSerializer._declared_fields.keys() _options_keys = Options.__annotations__.keys() diff --git a/src/openforms/registrations/contrib/email/models.py b/src/openforms/registrations/contrib/email/models.py index 4097084594..0609972637 100644 --- a/src/openforms/registrations/contrib/email/models.py +++ b/src/openforms/registrations/contrib/email/models.py @@ -95,7 +95,7 @@ class EmailConfig(SingletonModel): ], ) - class Meta: + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] verbose_name = _("Email registration configuration") def __str__(self): diff --git a/src/openforms/registrations/contrib/email/plugin.py b/src/openforms/registrations/contrib/email/plugin.py index 1e0e7b6e69..eee1d091b4 100644 --- a/src/openforms/registrations/contrib/email/plugin.py +++ b/src/openforms/registrations/contrib/email/plugin.py @@ -1,4 +1,5 @@ import html +import logging from mimetypes import types_map from typing import Any @@ -36,17 +37,62 @@ from .models import EmailConfig from .utils import get_registration_email_templates +logger = logging.getLogger(__name__) + @register(PLUGIN_ID) class EmailRegistration(BasePlugin[Options]): verbose_name = _("Email registration") configuration_options = EmailOptionsSerializer + @staticmethod + def get_recipients(submission: Submission, options: Options) -> list[str]: + state = submission.load_submission_value_variables_state() + # ensure we have a fallback + recipients: list[str] = options["to_emails"] + + # TODO: validate in the options that this key/variable exists, but we can't + # do that because variables get created *after* the registration options are + # submitted... + if variable_key := options.get("to_emails_from_variable"): + try: + variable = state.get_variable(variable_key) + except KeyError: + logger.info( + "Variable %s does not exist in submission %r", + variable_key, + submission.uuid, + extra={ + "variable_key": variable_key, + "form": submission.form.uuid, + "submission": submission.uuid, + }, + ) + else: + if variable_value := variable.value: + # Normalize to a list of email addresses. Note that a form component + # could be used with multiple=True, then it will already be a list of + # values. + if not isinstance(variable_value, list): + variable_value = [variable_value] + + # do not validate that the values are emails, if they're wrong values, + # we want to see this in error monitoring. + recipients = variable_value + logger.info( + "Determined recipients from form variable %r: %r", + variable_key, + recipients, + ) + + return recipients + def register_submission(self, submission: Submission, options: Options) -> None: config = EmailConfig.get_solo() config.apply_defaults_to(options) - self.send_registration_email(options["to_emails"], submission, options) + recipients = self.get_recipients(submission, options) + self.send_registration_email(recipients, submission, options) # ensure that the payment email is also sent if registration is deferred until # payment is completed @@ -187,8 +233,9 @@ def send_registration_email( def update_payment_status(self, submission: "Submission", options: Options): recipients = options.get("payment_emails") + if not recipients: - recipients = options["to_emails"] + recipients = self.get_recipients(submission, options) order_ids = submission.payments.get_completed_public_order_ids() extra_context = { diff --git a/src/openforms/registrations/contrib/email/tests/test_backend.py b/src/openforms/registrations/contrib/email/tests/test_backend.py index 3fd74b4a4a..47460b0de4 100644 --- a/src/openforms/registrations/contrib/email/tests/test_backend.py +++ b/src/openforms/registrations/contrib/email/tests/test_backend.py @@ -19,21 +19,14 @@ EmailContentTypeChoices, EmailEventChoices, ) -from openforms.forms.tests.factories import ( - FormDefinitionFactory, - FormFactory, - FormStepFactory, -) from openforms.payments.constants import PaymentStatus from openforms.payments.tests.factories import SubmissionPaymentFactory from openforms.submissions.attachments import attach_uploads_to_submission_step from openforms.submissions.exports import create_submission_export from openforms.submissions.models import Submission -from openforms.submissions.public_references import set_submission_reference from openforms.submissions.tests.factories import ( SubmissionFactory, SubmissionFileAttachmentFactory, - SubmissionReportFactory, SubmissionStepFactory, SubmissionValueVariableFactory, TemporaryFileUploadFactory, @@ -69,48 +62,32 @@ """ +def _get_sent_email(index: int = 0) -> tuple[mail.EmailMultiAlternatives, str, str]: + message = mail.outbox[index] + assert isinstance(message, mail.EmailMultiAlternatives) + text_body = message.body + html_body = message.alternatives[0][0] + assert isinstance(html_body, str) + return message, str(text_body), html_body + + @override_settings( DEFAULT_FROM_EMAIL="info@open-forms.nl", BASE_URL="https://example.com", LANGUAGE_CODE="nl", ) class EmailBackendTests(HTMLAssertMixin, TestCase): - @classmethod - def setUpTestData(cls): - fd = FormDefinitionFactory.create( - configuration={ - "components": [ - { - "key": "someField", - "label": "Some Field", - "type": "textfield", - }, - { - "key": "someList", - "label": "Some list", - "type": "textfield", - "multiple": True, - }, - ], - } - ) - form = FormFactory.create( - name="MyName", - internal_name="MyInternalName", - registration_backend="email", - ) - cls.fs = FormStepFactory.create(form=form, form_definition=fd) - cls.form = form - cls.fd = fd def setUp(self): super().setUp() self.addCleanup(GlobalConfiguration.clear_cache) + self.addCleanup(EmailConfig.clear_cache) def test_submission_with_email_backend(self): submission = SubmissionFactory.from_components( completed=True, + with_public_registration_reference=True, completed_on=timezone.make_aware(datetime(2021, 1, 1, 12, 0, 0)), components_list=[ {"key": "foo", "type": "textfield", "label": "foo"}, @@ -154,9 +131,12 @@ def test_submission_with_email_backend(self): }, language_code="nl", ) + step = ( + submission.submissionstep_set.get() # pyright: ignore[reportAttributeAccessIssue] + ) submission_file_attachment_1 = SubmissionFileAttachmentFactory.create( form_key="file1", - submission_step=submission.submissionstep_set.get(), + submission_step=step, file_name="my-foo.bin", content_type="application/foo", _component_configuration_path="components.2", @@ -164,18 +144,17 @@ def test_submission_with_email_backend(self): ) submission_file_attachment_2 = SubmissionFileAttachmentFactory.create( form_key="file2", - submission_step=submission.submissionstep_set.get(), + submission_step=step, file_name="my-bar.txt", content_type="text/bar", _component_configuration_path="components.3", _component_data_path="file2", ) - email_form_options = dict( - to_emails=["foo@bar.nl", "bar@foo.nl"], - ) - email_submission = EmailRegistration("email") - - set_submission_reference(submission) + email_form_options: Options = { + "to_emails": ["foo@bar.nl", "bar@foo.nl"], + "attach_files_to_email": None, + } + plugin = EmailRegistration("email") with patch( "openforms.registrations.contrib.email.utils.EmailConfig.get_solo", @@ -185,12 +164,12 @@ def test_submission_with_email_backend(self): content_text=TEST_TEMPLATE_NL, ), ): - email_submission.register_submission(submission, email_form_options) + plugin.register_submission(submission, email_form_options) # Verify that email was sent self.assertEqual(len(mail.outbox), 1) - message = mail.outbox[0] + message, message_text, message_html = _get_sent_email() self.assertEqual( message.subject, f"Subject: MyName - submission {submission.public_registration_reference}", @@ -199,15 +178,11 @@ def test_submission_with_email_backend(self): self.assertEqual(message.to, ["foo@bar.nl", "bar@foo.nl"]) # Check that the template is used - message_text = message.body - message_html = message.alternatives[0][0] self.assertHTMLValid(message_html) self.assertIn("
  • Backend
  • Frontend
  • ", message_html) @patch("openforms.registrations.contrib.email.plugin.EmailConfig.get_solo") @@ -820,19 +1012,22 @@ def test_with_global_config_attach_files(self, mock_get_solo): form__internal_name="MyInternalName", form__registration_backend="email", ) + step = ( + submission.submissionstep_set.get() # pyright: ignore[reportAttributeAccessIssue] + ) SubmissionFileAttachmentFactory.create( form_key="file1", - submission_step=submission.submissionstep_set.get(), + submission_step=step, file_name="my-foo.bin", content_type="application/foo", ) SubmissionFileAttachmentFactory.create( form_key="file2", - submission_step=submission.submissionstep_set.get(), + submission_step=step, file_name="my-bar.txt", content_type="text/bar", ) - email_submission = EmailRegistration("email") + plugin = EmailRegistration("email") for global_config, options_override in cases: with self.subTest( @@ -845,7 +1040,7 @@ def test_with_global_config_attach_files(self, mock_get_solo): } mock_get_solo.return_value = global_config - email_submission.register_submission(submission, email_form_options) + plugin.register_submission(submission, email_form_options) # Verify that email was sent self.assertEqual(len(mail.outbox), 1) @@ -885,17 +1080,18 @@ def test_user_defined_variables_included(self): value="test2", ) - email_form_options = dict(to_emails=["foo@bar.nl", "bar@foo.nl"]) - email_submission = EmailRegistration("email") + email_form_options: Options = { + "to_emails": ["foo@bar.nl", "bar@foo.nl"], + "attach_files_to_email": None, + } + plugin = EmailRegistration("email") - email_submission.register_submission(submission, email_form_options) + plugin.register_submission(submission, email_form_options) # Verify that email was sent self.assertEqual(len(mail.outbox), 1) - message = mail.outbox[0] - message_text = message.body - + _, message_text, _ = _get_sent_email() self.assertIn("User defined var 1: test1", message_text) self.assertIn("User defined var 2: test2", message_text) @@ -907,7 +1103,7 @@ def test_mime_body_parts_have_content_langauge(self): form__registration_backend="email", ) - email_submission = EmailRegistration("email") + plugin = EmailRegistration("email") with patch( "openforms.registrations.contrib.email.utils.EmailConfig.get_solo", @@ -916,15 +1112,12 @@ def test_mime_body_parts_have_content_langauge(self): content_text=TEST_TEMPLATE_NL, ), ): - email_submission.register_submission( - submission, {"to_emails": ["foo@example.com"]} - ) + plugin.register_submission(submission, {"to_emails": ["foo@example.com"]}) - message = mail.outbox[0] + message, message_text, message_html = _get_sent_email() self.assertEqual(message.extra_headers["Content-Language"], "en") - self.assertIn("Engels", message.body) - html_message = message.alternatives[0][0] - self.assertIn("Engels", html_message) + self.assertIn("Engels", message_text) + self.assertIn("Engels", message_html) @tag("gh-3144") def test_file_attachments_in_registration_email(self): @@ -1020,8 +1213,10 @@ def test_file_attachments_in_registration_email(self): ) attach_uploads_to_submission_step(submission_step) - subject, body_html, body_text = EmailRegistration.render_registration_email( - submission, is_payment_update=False + subject, body_html, body_text = ( + EmailRegistration.render_registration_email( # pyright: ignore[reportAttributeAccessIssue] + submission, is_payment_update=False + ) ) with self.subTest("Normal attachment"): @@ -1038,15 +1233,16 @@ def test_file_attachments_in_registration_email(self): def test_extra_headers(self): submission = SubmissionFactory.create() - email_form_options = dict( - to_emails=["foo@bar.nl", "bar@foo.nl"], - ) - email_submission = EmailRegistration("email") + email_form_options: Options = { + "to_emails": ["foo@bar.nl", "bar@foo.nl"], + "attach_files_to_email": None, + } + plugin = EmailRegistration("email") with patch( "openforms.registrations.contrib.email.plugin.send_mail_html" ) as mock_send: - email_submission.register_submission(submission, email_form_options) + plugin.register_submission(submission, email_form_options) args = mock_send.call_args.kwargs self.assertEqual( diff --git a/src/openforms/registrations/contrib/email/views.py b/src/openforms/registrations/contrib/email/views.py index 1cc6b119a6..6639dcfd37 100644 --- a/src/openforms/registrations/contrib/email/views.py +++ b/src/openforms/registrations/contrib/email/views.py @@ -16,7 +16,7 @@ def get_email_content(self): subject, html_content, text_content, - ) = EmailRegistration.render_registration_email( + ) = EmailRegistration.render_registration_email( # pyright: ignore[reportAttributeAccessIssue] self.object, is_payment_update=False ) content = html_content if mode == "html" else text_content diff --git a/src/openforms/registrations/tests/test_registration_hook.py b/src/openforms/registrations/tests/test_registration_hook.py index 65791261ec..4fd3eba8c8 100644 --- a/src/openforms/registrations/tests/test_registration_hook.py +++ b/src/openforms/registrations/tests/test_registration_hook.py @@ -312,7 +312,7 @@ def test_registration_backend_invalid_options(self): completed=True, form__registration_backend="email", form__registration_backend_options={}, - ) # Missing "to_email" option + ) # Missing "to_emails" option with ( self.subTest("On completion - does NOT raise"),