diff --git a/pyright.pyproject.toml b/pyright.pyproject.toml index 4c1a6ed7fd..cc79387715 100644 --- a/pyright.pyproject.toml +++ b/pyright.pyproject.toml @@ -14,6 +14,7 @@ include = [ "src/openforms/pre_requests/base.py", "src/openforms/pre_requests/registry.py", "src/openforms/prefill/base.py", + "src/openforms/prefill/co_sign.py", "src/openforms/prefill/registry.py", "src/openforms/registrations/base.py", "src/openforms/registrations/registry.py", @@ -31,6 +32,8 @@ include = [ # Interaction with the outside world "src/openforms/contrib/zgw/service.py", "src/openforms/contrib/objects_api/", + # Emails + "src/openforms/emails/templatetags/cosign_information.py", # Registrations "src/openforms/registrations/tasks.py", "src/openforms/registrations/contrib/email/config.py", @@ -40,9 +43,13 @@ include = [ "src/openforms/registrations/contrib/stuf_zds/typing.py", "src/openforms/registrations/contrib/objects_api/handlers/", "src/openforms/registrations/contrib/objects_api/plugin.py", + "src/openforms/registrations/contrib/objects_api/registration_variables.py", "src/openforms/registrations/contrib/objects_api/submission_registration.py", "src/openforms/registrations/contrib/objects_api/typing.py", "src/openforms/registrations/contrib/zgw_apis/", + # core submissions app + "src/openforms/submissions/cosigning.py", + "src/openforms/submissions/report.py", # our own template app/package on top of Django "src/openforms/template", # generic typing helpers diff --git a/src/openforms/authentication/api/serializers.py b/src/openforms/authentication/api/serializers.py index 0d3c046044..b9287f1958 100644 --- a/src/openforms/authentication/api/serializers.py +++ b/src/openforms/authentication/api/serializers.py @@ -93,7 +93,7 @@ class LoginOptionSerializer(serializers.Serializer): class CosignLoginInfoSerializer(LoginOptionSerializer): def get_attribute(self, form): - if not form.cosign_component: + if not form.has_cosign_enabled: return None # cosign component but no auth backends is an invalid config that should diff --git a/src/openforms/authentication/registry.py b/src/openforms/authentication/registry.py index cc9e80334d..1f21daebff 100644 --- a/src/openforms/authentication/registry.py +++ b/src/openforms/authentication/registry.py @@ -36,9 +36,12 @@ def get_options( form: Form | None = None, is_for_cosign: bool = False, ) -> list[LoginInfo]: - options = list() - if is_for_cosign and (not form or not form.cosign_component): - return options + options: list[LoginInfo] = [] + + # return empty list for forms without cosign + if is_for_cosign: + if not form or not form.has_cosign_enabled: + return [] for plugin_id in _iter_plugin_ids(form, self): if plugin_id not in self._registry: diff --git a/src/openforms/emails/confirmation_emails.py b/src/openforms/emails/confirmation_emails.py index bae7f7ac5a..91d9f60823 100644 --- a/src/openforms/emails/confirmation_emails.py +++ b/src/openforms/emails/confirmation_emails.py @@ -26,8 +26,9 @@ def get_confirmation_email_templates(submission: Submission) -> tuple[str, str]: with translation.override(submission.language_code): config = GlobalConfiguration.get_solo() custom_templates = getattr(submission.form, "confirmation_email_template", None) + cosign = submission.cosign_state - match (submission.requires_cosign, custom_templates): + match (cosign.is_required, custom_templates): # no cosign, definitely no custom templates exist case (False, None): return ( @@ -72,7 +73,7 @@ def get_confirmation_email_context_data(submission: Submission) -> dict[str, Any **get_variables_for_context(submission), "public_reference": submission.public_registration_reference, "registration_completed": submission.is_registered, - "waiting_on_cosign": submission.waiting_on_cosign, + "waiting_on_cosign": submission.cosign_state.is_waiting, } # use the ``|date`` filter so that the timestamp is first localized to the correct diff --git a/src/openforms/emails/templatetags/cosign_information.py b/src/openforms/emails/templatetags/cosign_information.py index 78caba7461..6abd458f64 100644 --- a/src/openforms/emails/templatetags/cosign_information.py +++ b/src/openforms/emails/templatetags/cosign_information.py @@ -1,12 +1,23 @@ +from typing import NotRequired, TypedDict + from django import template from django.template.loader import render_to_string +from openforms.submissions.models import Submission + register = template.Library() +class CosignInformationContext(TypedDict): + _submission: Submission + rendering_text: NotRequired[bool] + + @register.simple_tag(takes_context=True) -def cosign_information(context): +def cosign_information(context: CosignInformationContext) -> str: submission = context["_submission"] + if not (cosign := submission.cosign_state).is_required: + return "" if context.get("rendering_text"): template_name = "emails/templatetags/cosign_information.txt" @@ -14,8 +25,8 @@ def cosign_information(context): template_name = "emails/templatetags/cosign_information.html" tag_context = { - "cosign_complete": submission.cosign_complete, - "waiting_on_cosign": submission.waiting_on_cosign, - "cosigner_email": submission.cosigner_email, + "cosign_complete": cosign.is_signed, + "waiting_on_cosign": cosign.is_waiting, + "cosigner_email": cosign.email, } return render_to_string(template_name, tag_context) diff --git a/src/openforms/forms/models/form.py b/src/openforms/forms/models/form.py index fc795376c6..f08dccb956 100644 --- a/src/openforms/forms/models/form.py +++ b/src/openforms/forms/models/form.py @@ -2,6 +2,7 @@ import uuid as _uuid from contextlib import suppress from copy import deepcopy +from functools import cached_property from typing import Literal from django.conf import settings @@ -24,7 +25,6 @@ from openforms.authentication.registry import register as authentication_register from openforms.config.models import GlobalConfiguration from openforms.data_removal.constants import RemovalMethods -from openforms.formio.typing import Component from openforms.formio.validators import variable_key_validator from openforms.payments.fields import PaymentBackendChoiceField from openforms.payments.registry import register as payment_register @@ -40,8 +40,6 @@ User = get_user_model() logger = logging.getLogger(__name__) -_sentinel = object() - class FormQuerySet(models.QuerySet): def live(self): @@ -438,23 +436,19 @@ def get_authentication_backends_display(self): "authentication backend(s)" ) - _cosign_component = _sentinel + @cached_property + def has_cosign_enabled(self) -> bool: + """ + Check if cosign is enabled by checking the presence of a cosign (v2) component. - def get_cosign_component(self) -> Component | None: + We don't return the component itself, as you should use + :class:`openforms.submissions.cosigning.CosignState` to check the state, which + can take dynamic logic rules into account. + """ for component in self.iter_components(): if component["type"] == "cosign": - return component - - @property - def cosign_component(self) -> Component | None: - if self._cosign_component is _sentinel: - self._cosign_component = self.get_cosign_component() - return self._cosign_component - - @property - def cosigning_required(self) -> bool: - cosign_component = self.get_cosign_component() - return cosign_component and cosign_component.get("validate", {}).get("required") + return True + return False @property def login_required(self) -> bool: diff --git a/src/openforms/prefill/co_sign.py b/src/openforms/prefill/co_sign.py index 891816d1a4..057436fe8d 100644 --- a/src/openforms/prefill/co_sign.py +++ b/src/openforms/prefill/co_sign.py @@ -10,6 +10,7 @@ import logging from openforms.authentication.service import AuthAttribute +from openforms.submissions.cosigning import CosignV1Data from openforms.submissions.models import Submission from .models import PrefillConfig @@ -57,10 +58,16 @@ def add_co_sign_representation( plugin, ) + _cosign_data: CosignV1Data = submission.co_sign_data.copy() + values, representation = plugin.get_co_sign_values( - submission, - submission.co_sign_data["identifier"], + submission, _cosign_data["identifier"] + ) + _cosign_data.update( + { + "fields": values, + "representation": representation, + } ) - submission.co_sign_data["fields"] = values - submission.co_sign_data["representation"] = representation + submission.co_sign_data.update(_cosign_data) submission.save(update_fields=["co_sign_data"]) diff --git a/src/openforms/registrations/contrib/objects_api/registration_variables.py b/src/openforms/registrations/contrib/objects_api/registration_variables.py index 6036a1b8f3..6847a0156e 100644 --- a/src/openforms/registrations/contrib/objects_api/registration_variables.py +++ b/src/openforms/registrations/contrib/objects_api/registration_variables.py @@ -1,19 +1,17 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING from django.utils.translation import gettext_lazy as _ -from openforms.authentication.service import AuthAttribute, BaseAuth +from openforms.authentication.service import AuthAttribute from openforms.plugins.registry import BaseRegistry +from openforms.submissions.cosigning import CosignV2Data +from openforms.submissions.models import Submission from openforms.variables.base import BaseStaticVariable from openforms.variables.constants import FormVariableDataTypes -from .models import ObjectsAPISubmissionAttachment - -if TYPE_CHECKING: - from openforms.submissions.models import Submission +from .models import ObjectsAPIRegistrationData, ObjectsAPISubmissionAttachment class Registry(BaseRegistry[BaseStaticVariable]): @@ -47,7 +45,10 @@ class PdfUrl(BaseStaticVariable): def get_initial_value(self, submission: Submission | None = None): if submission is None: return None - return submission.objects_api_registration_data.pdf_url + _data: ObjectsAPIRegistrationData = ( + submission.objects_api_registration_data # pyright: ignore[reportAttributeAccessIssue] + ) + return _data.pdf_url @register("csv_url") @@ -58,7 +59,10 @@ class CsvUrl(BaseStaticVariable): def get_initial_value(self, submission: Submission | None = None): if submission is None: return None - return submission.objects_api_registration_data.csv_url + _data: ObjectsAPIRegistrationData = ( + submission.objects_api_registration_data # pyright: ignore[reportAttributeAccessIssue] + ) + return _data.csv_url @register("attachment_urls") @@ -127,19 +131,20 @@ class Cosign(BaseStaticVariable): def get_initial_value( self, submission: Submission | None = None - ) -> BaseAuth | None: - if not submission or not submission.cosign_complete: + ) -> CosignV2Data | None: + if not submission or not (cosign := submission.cosign_state).is_signed: return None - return submission.co_sign_data + return cosign.signing_details def get_cosign_value(submission: Submission | None, attribute: AuthAttribute) -> str: - if not submission or not submission.cosign_complete: + if not submission or not (cosign := submission.cosign_state).is_signed: return "" - if submission.co_sign_data["attribute"] == attribute: - return submission.co_sign_data["value"] + details = cosign.signing_details + if details["attribute"] == attribute: + return details["value"] return "" @@ -152,15 +157,10 @@ class CosignDate(BaseStaticVariable): def get_initial_value( self, submission: Submission | None = None ) -> datetime | None: - if not submission or not submission.cosign_complete: + if not submission or not (cosign := submission.cosign_state).is_signed: return None - - if (cosign_date := submission.co_sign_data.get("cosign_date")) is None: - # Can be the case on existing submissions, at some point we can switch back to - # `__getitem__` ([...]). - return None - - return datetime.fromisoformat(cosign_date) + cosign_date = cosign.signing_details.get("cosign_date") + return datetime.fromisoformat(cosign_date) if cosign_date else None @register("cosign_bsn") diff --git a/src/openforms/registrations/contrib/objects_api/submission_registration.py b/src/openforms/registrations/contrib/objects_api/submission_registration.py index c08c0def91..6c3a31d895 100644 --- a/src/openforms/registrations/contrib/objects_api/submission_registration.py +++ b/src/openforms/registrations/contrib/objects_api/submission_registration.py @@ -4,7 +4,16 @@ from contextlib import contextmanager from datetime import date, datetime from decimal import Decimal -from typing import Any, Generic, Iterator, Literal, TypeVar, cast, override +from typing import ( + Any, + Generic, + Iterator, + Literal, + TypeVar, + assert_never, + cast, + override, +) from django.db.models import F @@ -91,7 +100,7 @@ def _resolve_documenttype( description = options["iot_attachment"] url_ref = options.get("informatieobjecttype_attachment", "") case _: # pragma: no cover - raise RuntimeError(f"Unhandled field '{field}'.") + assert_never(field) # descriptions only work if a catalogue is provided to look up the document type # inside it @@ -367,21 +376,16 @@ def get_payment_context_data(submission: Submission) -> dict[str, Any]: def get_cosign_context_data( submission: Submission, ) -> dict[str, str | datetime] | None: - if not submission.cosign_complete: + if not (cosign := submission.cosign_state).is_signed: return None - else: - # date can be missing on existing submissions, so fallback to an empty string - date = ( - datetime.fromisoformat(submission.co_sign_data["cosign_date"]) - if "cosign_date" in submission.co_sign_data - else "" - ) - return { - "bsn": get_cosign_value(submission, AuthAttribute.bsn), - "kvk": get_cosign_value(submission, AuthAttribute.kvk), - "pseudo": get_cosign_value(submission, AuthAttribute.pseudo), - "date": date, - } + + cosign_date = cosign.signing_details.get("cosign_date") + return { + "bsn": get_cosign_value(submission, AuthAttribute.bsn), + "kvk": get_cosign_value(submission, AuthAttribute.kvk), + "pseudo": get_cosign_value(submission, AuthAttribute.pseudo), + "date": datetime.fromisoformat(cosign_date) if cosign_date else "", + } @override def get_record_data( diff --git a/src/openforms/registrations/contrib/stuf_zds/plugin.py b/src/openforms/registrations/contrib/stuf_zds/plugin.py index f8d9467701..11077daf60 100644 --- a/src/openforms/registrations/contrib/stuf_zds/plugin.py +++ b/src/openforms/registrations/contrib/stuf_zds/plugin.py @@ -243,7 +243,11 @@ def register_submission( zaak_options: ZaakOptions = { **options, "omschrijving": submission.form.admin_name, - "co_sign_data": submission.co_sign_data if submission.co_sign_data else {}, + "cosigner": ( + cosign.signing_details["value"] + if (cosign := submission.cosign_state).is_signed + else "" + ), } with get_client(options=zaak_options) as client: diff --git a/src/openforms/registrations/contrib/stuf_zds/tests/test_backend.py b/src/openforms/registrations/contrib/stuf_zds/tests/test_backend.py index 27d59b8a5d..48ea9c3e93 100644 --- a/src/openforms/registrations/contrib/stuf_zds/tests/test_backend.py +++ b/src/openforms/registrations/contrib/stuf_zds/tests/test_backend.py @@ -199,6 +199,7 @@ def test_plugin(self, m, mock_task): { "key": "language_code", }, + {"key": "cosignerEmail", "type": "cosign"}, ], form__name="my-form", bsn="111222333", @@ -214,7 +215,8 @@ def test_plugin(self, m, mock_task): "language_code": "Dothraki", # some form widget defined by form designer }, language_code="en", - co_sign_data={"value": "123456782"}, + cosigned=True, + co_sign_data__value="123456782", ) attachment = SubmissionFileAttachmentFactory.create( diff --git a/src/openforms/registrations/tasks.py b/src/openforms/registrations/tasks.py index 0bda42a57f..136037889a 100644 --- a/src/openforms/registrations/tasks.py +++ b/src/openforms/registrations/tasks.py @@ -189,7 +189,7 @@ def register_submission(submission_id: int, event: PostSubmissionEvents | str) - ) return - if submission.waiting_on_cosign: + if submission.cosign_state.is_waiting: logger.debug( "Skipping registration for submission '%s' as it hasn't been co-signed yet.", submission, diff --git a/src/openforms/submissions/cosigning.py b/src/openforms/submissions/cosigning.py new file mode 100644 index 0000000000..c3bae8bd1a --- /dev/null +++ b/src/openforms/submissions/cosigning.py @@ -0,0 +1,195 @@ +""" +Expose utilities for submissions that require cosigning. + +Cosigning is the practice where a second actor "approves" the submission. The original +submitter specifies who needs to cosign, e.g. a partner. + +Currently there are two versions of cosigning, with a third in the making: + +* v1: a cosign component directly in the form, which starts an additional authentication + flow. When the submission is completed, it is already cosigned. This flow causes + problems if the DigiD session is remembered and the original submitter is + automatically logged in again. +* v2: still a component in the form, but cosigning is now out-of-band. The original + submitter provides the email address of the cosigner who will receive the request + via email. When the submission is completed, it is not yet cosigned. +* v3: will probably be similar to v2, but more rigid and without Formio components. +""" + +from __future__ import annotations + +import logging +from functools import cached_property +from typing import TYPE_CHECKING, NotRequired, TypedDict + +from openforms.authentication.constants import AuthAttribute +from openforms.authentication.typing import FormAuth +from openforms.formio.typing import Component +from openforms.submissions.models import submission +from openforms.typing import JSONObject + +if TYPE_CHECKING: + from .models import Submission + +logger = logging.getLogger(__name__) + +type CosignData = CosignV1Data | CosignV2Data + + +class CosignV1Data(TypedDict): + """ + The shape of data stored in + :attr:`openforms.submissions.models.Submission.co_sign_data`. + """ + + plugin: str + identifier: str + representation: NotRequired[str] + co_sign_auth_attribute: AuthAttribute + fields: JSONObject + + +class CosignV2Data(FormAuth): + """ + The shape of data stored in + :attr:`openforms.submissions.models.Submission.co_sign_data`. + + In cosign v2, the data inherits from the standard :class:`FormAuth` data, so it + contains the auth plugin, attribute and identifier value. + """ + + cosign_date: NotRequired[str] + """ + ISO-8601 formatted datetime indicating when the cosigner confirmed the submission. + + The key may be absent in legacy submissions, which is why it's marked as + ``NotRequired``. It was added in Open Forms 2.7.0 + + .. todo:: DeprecationWarning -> remove NotRequired in Open Forms 4.0. + """ + + +class CosignState: + """ + Encapsulate all the cosign state of a submission. + """ + + def __init__(self, submission: Submission): + self.submission = submission + + def __repr__(self): + reference = ( + self.submission.public_registration_reference or self.submission.uuid + ) + return f">" + + def _find_component(self) -> Component | None: + """ + Discover the Formio cosign component in the submission/form. + """ + # use the complete view on the Formio component tree(s) + configuration_wrapper = self.submission.total_configuration_wrapper + + # there should only be one cosign component, so we check our flatmap to find the + # component. + for component in configuration_wrapper.component_map.values(): + if component["type"] == "cosign": + return component + return None + + @property + def is_required(self) -> bool: + """ + Determine if cosigning is required for the submission or not. + + Cosign is considered required in either of the following conditions: + + * the cosign component in the form is required (even after evaluating form logic) + * the cosign component in the form is optional and an email address has been + provided - this indicates the submitter wanted someone to cosign it given the + choice. + + .. note:: This does not consider cosign v1, as the cosigning is required to be + able to complete the submission. + """ + cosign_component = self._find_component() + if cosign_component is None: + return False + + # if the component itself is marked as required, we know for sure cosigning is + # required + required = cosign_component.get("validate", {}).get("required", False) + if required: + return True + + # otherwise, we check if there's a cosigner email specified + return bool(self.email) + + @property + def is_waiting(self) -> bool: + """ + Indicate if the submission is still waiting to be cosigned. + """ + if self.is_signed: + return False + + # Cosign not complete, but required or the component was filled in the form + if self.is_required: + return True + + # Cosign optional or possibly not relevant at all. + return False + + @property + def is_signed(self) -> bool: + """ + Indicate if the submission has been cosigned. + """ + return self.submission.cosign_complete + + @cached_property + def email(self) -> str: + """ + Get the email address of the cosigner. + """ + cosign_component = self._find_component() + assert ( + cosign_component is not None + ), "You can only look up the email in forms that have a cosign component" + + variables_state = self.submission.load_submission_value_variables_state() + values = variables_state.get_data(as_formio_data=True) + if (key := cosign_component["key"]) not in values: + logger.info( + "Inconsistent state - there is a cosign component, but no value is" + "associated with it (submission %s).", + self.submission.uuid, + extra={"submission": submission.uuid}, + ) + return "" + cosigner_email = values[key] + assert isinstance(cosigner_email, str) + return cosigner_email + + @property + def legacy_signing_details(self) -> CosignData: + """ + Expose the legacy cosign data. + + In the legacy format, we don't know (at the type level) whether cosign v1 or + v2 was used. + """ + return self.submission.co_sign_data + + @property + def signing_details(self) -> CosignV2Data: + """ + Expose the cosign details. + + Legacy (cosign v1) cosign details are unsupported, use + :attr:`legacy_signing_details` for those instead. + """ + assert ( + self.is_signed + ), "You may only access the signing details after validating the 'is_signed' state." + return self.submission.co_sign_data diff --git a/src/openforms/submissions/models/submission.py b/src/openforms/submissions/models/submission.py index 68c3db52d1..c770490228 100644 --- a/src/openforms/submissions/models/submission.py +++ b/src/openforms/submissions/models/submission.py @@ -33,6 +33,7 @@ RegistrationStatuses, SubmissionValueVariableSources, ) +from ..cosigning import CosignState from ..pricing import get_submission_price from ..query import SubmissionManager from ..serializers import CoSignDataSerializer @@ -483,7 +484,7 @@ def is_ready_to_hash_identifying_attributes(self) -> bool: return False # completed, but not cosigned yet -> registration after cosigning requires # unhashed attributes - if self.is_completed and self.waiting_on_cosign: + if self.is_completed and self.cosign_state.is_waiting: return False # the submission has not been submitted/completed/finalized, so it will @@ -525,6 +526,7 @@ def remove_sensitive_data(self): self._is_cleaned = True if self.co_sign_data: + # FIXME: this only deals with cosign v1 and not v2 # We do keep the representation, as that is used in PDF and confirmation e-mail # generation and is usually a label derived from the source fields. self.co_sign_data.update( @@ -612,7 +614,7 @@ def render_confirmation_page_title(self) -> SafeString: config = GlobalConfiguration.get_solo() template = ( config.cosign_submission_confirmation_title - if self.requires_cosign + if self.cosign_state.is_required else config.submission_confirmation_title ) return render_from_string( @@ -624,7 +626,8 @@ def render_confirmation_page(self) -> SafeString: from openforms.variables.utils import get_variables_for_context config = GlobalConfiguration.get_solo() - if self.requires_cosign: + cosign = self.cosign_state + if cosign_required := cosign.is_required: template = config.cosign_submission_confirmation_template elif not (template := self.form.submission_confirmation_template): template = config.submission_confirmation_template @@ -636,7 +639,7 @@ def render_confirmation_page(self) -> SafeString: "_submission": self, "_form": self.form, # should be the same as self.form "public_reference": self.public_registration_reference, - "cosigner_email": self.cosigner_email or "", + "cosigner_email": cosign.email if cosign_required else "", **get_variables_for_context(submission=self), } return render_from_string(template, context_data, backend=openforms_backend) @@ -739,6 +742,7 @@ def data(self) -> dict[str, Any]: return values_state.get_data() def get_co_signer(self) -> str: + # XXX only deals with cosign v1 if not self.co_sign_data: return "" if not (co_signer := self.co_sign_data.get("representation", "")): @@ -828,34 +832,8 @@ def get_prefilled_data(self): return self._prefilled_data @cached_property - def cosigner_email(self) -> str | None: - from openforms.formio.service import iterate_data_with_components - - for form_step in self.form.formstep_set.select_related("form_definition"): - for component_with_data_item in iterate_data_with_components( - form_step.form_definition.configuration, self.data - ): - if component_with_data_item.component["type"] == "cosign": - return glom( - self.data, component_with_data_item.data_path, default=None - ) - - @property - def requires_cosign(self) -> bool: - if self.form.cosigning_required or self.cosigner_email: - return True - return False - - @property - def waiting_on_cosign(self) -> bool: - if self.cosign_complete: - return False - - # Cosign not complete, but required or the component was filled in the form - if self.requires_cosign: - return True - - return False + def cosign_state(self) -> CosignState: + return CosignState(submission=self) @property def default_registration_backend_key(self) -> RegistrationBackendKey | None: diff --git a/src/openforms/submissions/models/submission_value_variable.py b/src/openforms/submissions/models/submission_value_variable.py index 73ded227ea..2e3722e55b 100644 --- a/src/openforms/submissions/models/submission_value_variable.py +++ b/src/openforms/submissions/models/submission_value_variable.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import date, datetime, time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, overload from django.core.serializers.json import DjangoJSONEncoder from django.db import models @@ -33,7 +33,7 @@ def default(self, obj: JSONEncodable | JSONSerializable) -> JSONEncodable: @dataclass class SubmissionValueVariablesState: - submission: "Submission" + submission: Submission _variables: dict[str, SubmissionValueVariable] | None = field( init=False, default=None ) @@ -56,11 +56,37 @@ def saved_variables(self) -> dict[str, SubmissionValueVariable]: def get_variable(self, key: str) -> SubmissionValueVariable: return self.variables[key] + @overload def get_data( self, + *, + as_formio_data: Literal[True], submission_step: SubmissionStep | None = None, return_unchanged_data: bool = True, - ) -> DataMapping: + ) -> FormioData: ... + + @overload + def get_data( + self, + *, + as_formio_data: Literal[False] = False, + submission_step: SubmissionStep | None = None, + return_unchanged_data: bool = True, + ) -> DataMapping: ... + + def get_data( + self, + *, + as_formio_data: bool = False, + submission_step: SubmissionStep | None = None, + return_unchanged_data: bool = True, + ) -> DataMapping | FormioData: + """ + Return the values of the dynamic variables in the submission. + + :arg as_formio_data: set to ``True`` to get the :class:`FormioData` + datastructure instead of the underlying nested dictionaries. + """ submission_variables = self.saved_variables if submission_step: submission_variables = self.get_variables_in_submission_step( @@ -79,7 +105,7 @@ def get_data( if variable.source != SubmissionValueVariableSources.sensitive_data_cleaner: formio_data[variable_key] = variable.value - return formio_data.data + return formio_data if as_formio_data else formio_data.data def get_variables_in_submission_step( self, diff --git a/src/openforms/submissions/report.py b/src/openforms/submissions/report.py index 5eb30e722b..dfb0c37e8b 100644 --- a/src/openforms/submissions/report.py +++ b/src/openforms/submissions/report.py @@ -6,7 +6,7 @@ import logging from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import cast from django.utils.html import format_html from django.utils.safestring import SafeString @@ -15,8 +15,8 @@ from openforms.authentication.service import AuthAttribute from openforms.forms.models import Form -if TYPE_CHECKING: - from .models import Submission +from .cosigning import CosignV1Data +from .models import Submission logger = logging.getLogger(__name__) @@ -38,36 +38,34 @@ def form(self) -> Form: @property def needs_privacy_consent(self) -> bool: - return self.submission.form.get_statement_checkbox_required( - "ask_privacy_consent" - ) + return self.form.get_statement_checkbox_required("ask_privacy_consent") @property def needs_statement_of_truth(self) -> bool: - return self.submission.form.get_statement_checkbox_required( - "ask_statement_of_truth" - ) + return self.form.get_statement_checkbox_required("ask_statement_of_truth") @property def show_payment_info(self) -> bool: - return self.submission.payment_required and self.submission.price + return bool(self.submission.payment_required and self.submission.price) @property def co_signer(self) -> str: """Retrieve and normalize data about the co-signer of a form""" - - if not (co_sign_data := self.submission.co_sign_data): + details = self.submission.cosign_state.legacy_signing_details + if not details: return "" # XXX this is something present in cosign v2 but not v1, which happens after the # PDF is generated. Generating the PDF again after it's cosigned otherwise # crashes. - if "cosign_date" in co_sign_data: + if "cosign_date" in details: return "" - representation = co_sign_data.get("representation") or "" - identifier = co_sign_data["identifier"] - co_sign_auth_attribute = co_sign_data["co_sign_auth_attribute"] + details = cast(CosignV1Data, details) + + representation = details.get("representation") or "" + identifier = details["identifier"] + co_sign_auth_attribute = details["co_sign_auth_attribute"] auth_attribute_label = AuthAttribute[co_sign_auth_attribute].label if not representation: @@ -86,14 +84,14 @@ def confirmation_page_content(self) -> SafeString: # a submission that requires cosign is by definition not finished yet, so # displaying 'confirmation page content' doesn't make sense. Instead, we # render a simple notice describing the cosigning requirement. - if self.submission.requires_cosign: + if (cosign := self.submission.cosign_state).is_required: return format_html( "

{content}

", content=_( "This PDF was generated before submission processing has started " "because it needs to be cosigned first. The cosign request email " "was sent to {email}." - ).format(email=self.submission.cosigner_email), + ).format(email=cosign.email), ) # the content is already escaped by Django's rendering engine and mark_safe diff --git a/src/openforms/submissions/tasks/emails.py b/src/openforms/submissions/tasks/emails.py index 0a7e7d2bf1..4fb4665392 100644 --- a/src/openforms/submissions/tasks/emails.py +++ b/src/openforms/submissions/tasks/emails.py @@ -68,7 +68,8 @@ def send_confirmation_email(submission_id: int) -> None: if ( not submission.confirmation_email_sent or ( - not submission.cosign_confirmation_email_sent and submission.cosign_complete + not submission.cosign_confirmation_email_sent + and submission.cosign_state.is_signed ) or ( not submission.payment_complete_confirmation_email_sent @@ -84,11 +85,12 @@ def send_email_cosigner(submission_id: int) -> None: with translation.override(submission.language_code): config = GlobalConfiguration.get_solo() + cosign = submission.cosign_state - if not (recipient := submission.cosigner_email): + if not cosign.is_required or not (recipient := cosign.email): logger.warning( "No co-signer email found in the form. Skipping co-sign email for submission %d.", - submission.id, + submission.pk, ) return @@ -162,7 +164,7 @@ def schedule_emails(submission_id: int) -> None: # Figure out which email(s) should be sent # - if submission.waiting_on_cosign and not submission.cosign_request_email_sent: + if submission.cosign_state.is_waiting and not submission.cosign_request_email_sent: send_email_cosigner.delay(submission_id) if not submission.form.send_confirmation_email: @@ -170,7 +172,7 @@ def schedule_emails(submission_id: int) -> None: "Form %d is configured to not send a confirmation email for submission %d, " "skipping the confirmation e-mail.", submission.form.id, - submission.id, + submission.pk, ) logevent.confirmation_email_skip(submission) return @@ -185,7 +187,7 @@ def schedule_emails(submission_id: int) -> None: execution_options["countdown"] = settings.PAYMENT_CONFIRMATION_EMAIL_TIMEOUT send_confirmation_email.apply_async( - args=(submission.id,), + args=(submission.pk,), **execution_options, ) logevent.confirmation_email_scheduled( diff --git a/src/openforms/submissions/tests/factories.py b/src/openforms/submissions/tests/factories.py index b7c0c06fd1..77d4e1ba7a 100644 --- a/src/openforms/submissions/tests/factories.py +++ b/src/openforms/submissions/tests/factories.py @@ -12,6 +12,7 @@ import magic from glom import PathAccessError, glom +from openforms.authentication.constants import AuthAttribute from openforms.forms.tests.factories import ( FormDefinitionFactory, FormFactory, @@ -152,6 +153,25 @@ class Params: completed=True, public_registration_reference=factory.LazyFunction(get_random_reference), ) + cosigned = factory.Trait( + completed=True, + cosign_request_email_sent=True, + cosign_privacy_policy_accepted=True, + cosign_statement_of_truth_accepted=True, + cosign_complete=True, + co_sign_data=factory.Dict( + { + "plugin": "demo", + "attribute": AuthAttribute.bsn, + "value": "123456782", + "cosign_date": factory.LazyAttribute( + lambda o: ( + o.factory_parent.completed_on + timedelta(days=1) + ).isoformat() + ), + } + ), + ) @factory.post_generation def prefill_data(obj, create, extracted, **kwargs): diff --git a/src/openforms/submissions/tests/test_cosign_state.py b/src/openforms/submissions/tests/test_cosign_state.py new file mode 100644 index 0000000000..a0913b92fc --- /dev/null +++ b/src/openforms/submissions/tests/test_cosign_state.py @@ -0,0 +1,150 @@ +from django.test import TestCase + +from ..cosigning import CosignState +from .factories import SubmissionFactory + + +class CosignStateTests(TestCase): + + def test_string_representation(self): + submission = SubmissionFactory.build(public_registration_reference="OF-123") + cosign = CosignState(submission=submission) + + str_repr = str(cosign) + + self.assertEqual( + str_repr, ">" + ) + + def test_cosign_required_state(self): + submissions = ( + ( + SubmissionFactory.create(cosigned=False), + False, + ), + ( + SubmissionFactory.from_components( + [ + { + "type": "cosign", + "key": "cosign", + "hidden": False, + "validate": {"required": False}, + } + ], + cosigned=False, + ), + False, + ), + ( + SubmissionFactory.from_components( + [ + { + "type": "cosign", + "key": "cosign", + "hidden": False, + "validate": {"required": True}, + } + ], + cosigned=True, + ), + True, + ), + ( + SubmissionFactory.from_components( + [ + { + "type": "cosign", + "key": "cosign", + "hidden": False, + "validate": {"required": False}, + } + ], + submitted_data={"cosign": "cosigner@example.com"}, + cosigned=False, + ), + True, + ), + ( + SubmissionFactory.from_components( + [ + { + "type": "cosign", + "key": "cosign", + "hidden": False, + "validate": {"required": False}, + } + ], + submitted_data={"cosign": "cosigner@example.com"}, + cosigned=True, + ), + True, + ), + ) + + for index, (submission, expected) in enumerate(submissions): + with self.subTest(f"submission at index {index}"): + cosign = CosignState(submission=submission) + + self.assertEqual(cosign.is_required, expected) + + def test_is_waiting(self): + submissions = ( + # without cosign + ( + SubmissionFactory.create(cosigned=False), + False, + ), + # not signed yet + ( + SubmissionFactory.from_components( + [ + { + "type": "cosign", + "key": "cosign", + "hidden": False, + "validate": {"required": True}, + } + ], + cosigned=False, + ), + True, + ), + ( + SubmissionFactory.from_components( + [ + { + "type": "cosign", + "key": "cosign", + "hidden": False, + "validate": {"required": False}, + } + ], + submitted_data={"cosign": "cosigner@example.com"}, + cosigned=False, + ), + True, + ), + # signed + ( + SubmissionFactory.from_components( + [ + { + "type": "cosign", + "key": "cosign", + "hidden": False, + "validate": {"required": False}, + } + ], + submitted_data={"cosign": "cosigner@example.com"}, + cosigned=True, + ), + False, + ), + ) + + for index, (submission, expected) in enumerate(submissions): + with self.subTest(f"submission at index {index}"): + cosign = CosignState(submission=submission) + + self.assertEqual(cosign.is_waiting, expected) diff --git a/src/openforms/submissions/tests/test_models.py b/src/openforms/submissions/tests/test_models.py index 04ccadf8bc..4f6b5ca2ce 100644 --- a/src/openforms/submissions/tests/test_models.py +++ b/src/openforms/submissions/tests/test_models.py @@ -568,13 +568,13 @@ def test_get_cosigner_email(self): ) with self.subTest("simple"): - self.assertEqual(submission1.cosigner_email, "test@test.nl") + self.assertEqual(submission1.cosign_state.email, "test@test.nl") with self.subTest("in fieldset"): - self.assertEqual(submission2.cosigner_email, "test@test.nl") + self.assertEqual(submission2.cosign_state.email, "test@test.nl") with self.subTest("nested"): - self.assertEqual(submission3.cosigner_email, "test@test.nl") + self.assertEqual(submission3.cosign_state.email, "test@test.nl") def test_clear_execution_state_without_execution_state(self): submission = SubmissionFactory.create() diff --git a/src/openforms/submissions/utils.py b/src/openforms/submissions/utils.py index c88100587e..d2bf778f2e 100644 --- a/src/openforms/submissions/utils.py +++ b/src/openforms/submissions/utils.py @@ -169,16 +169,17 @@ def send_confirmation_email(submission: Submission) -> None: logger.warning( "Could not determine the recipient e-mail address for submission %d, " "skipping the confirmation e-mail.", - submission.id, + submission.pk, ) logevent.confirmation_email_skip(submission) return cc_emails = [] + cosign = submission.cosign_state should_cosigner_be_in_cc = ( - submission.cosign_complete and not submission.cosign_confirmation_email_sent + cosign.is_signed and not submission.cosign_confirmation_email_sent ) - if should_cosigner_be_in_cc and (cosigner_email := submission.cosigner_email): + if should_cosigner_be_in_cc and (cosigner_email := cosign.email): cc_emails.append(cosigner_email) context = get_confirmation_email_context_data(submission) @@ -237,7 +238,7 @@ def initialise_user_defined_variables(submission: Submission): if variable.form_variable.source == FormVariableSources.user_defined } SubmissionValueVariable.objects.bulk_create( - [variable for key, variable in variables.items() if not variable.pk] + [variable for variable in variables.values() if not variable.pk] ) diff --git a/src/stuf/stuf_zds/client.py b/src/stuf/stuf_zds/client.py index c3e9a9f5c4..c69c8ff402 100644 --- a/src/stuf/stuf_zds/client.py +++ b/src/stuf/stuf_zds/client.py @@ -94,7 +94,7 @@ class ZaakOptions(TypedDict): ] # extra's omschrijving: str - co_sign_data: NotRequired[dict[str, str]] + cosigner: NotRequired[str] # identifier of the cosigner (BSN) class NoServiceConfigured(RuntimeError): @@ -241,11 +241,7 @@ def create_zaak( "zds_zaaktype_status_omschrijving" ), "zaak_omschrijving": self.zds_options["omschrijving"], - "co_signer": ( - co_sign_data["value"] - if (co_sign_data := self.zds_options.get("co_sign_data")) - else {} - ), + "co_signer": self.zds_options.get("cosigner"), "zaak_identificatie": zaak_identificatie, "extra": extra_data, "global_config": GlobalConfiguration.get_solo(), diff --git a/src/stuf/stuf_zds/tests/test_backend.py b/src/stuf/stuf_zds/tests/test_backend.py index 56608e2823..b2e0abd0b7 100644 --- a/src/stuf/stuf_zds/tests/test_backend.py +++ b/src/stuf/stuf_zds/tests/test_backend.py @@ -208,7 +208,7 @@ def test_create_zaak_identificatie(self, m): def test_create_zaak(self, m): client = StufZDSClient(self.service, self.options) - self.options.update({"co_sign_data": {"value": "123456782"}}) + self.options.update({"cosigner": "123456782"}) m.post( self.service.soap_service.url, content=load_mock("creeerZaak.xml"),