diff --git a/src/openforms/forms/api/serializers/logic/action_serializers.py b/src/openforms/forms/api/serializers/logic/action_serializers.py index a936db3756..0a46e0096e 100644 --- a/src/openforms/forms/api/serializers/logic/action_serializers.py +++ b/src/openforms/forms/api/serializers/logic/action_serializers.py @@ -2,6 +2,7 @@ from collections import Counter from datetime import date +from django.core.serializers.json import DjangoJSONEncoder from django.utils.translation import gettext_lazy as _ from drf_polymorphic.serializers import PolymorphicSerializer @@ -64,6 +65,7 @@ class LogicValueActionSerializer(serializers.Serializer): "(other) Form.io components." ), validators=[JsonLogicValidator()], + encoder=DjangoJSONEncoder, ) diff --git a/src/openforms/submissions/api/serializers.py b/src/openforms/submissions/api/serializers.py index 06c544ca22..cbd9c87631 100644 --- a/src/openforms/submissions/api/serializers.py +++ b/src/openforms/submissions/api/serializers.py @@ -1,10 +1,11 @@ from copy import deepcopy from dataclasses import dataclass -from datetime import date, datetime, time, timedelta +from datetime import timedelta from typing import TypedDict from django.conf import settings from django.contrib.sessions.backends.base import SessionBase +from django.core.serializers.json import DjangoJSONEncoder from django.db import transaction from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -35,10 +36,9 @@ FormDefinitionSerializer, LogicComponentActionSerializer, ) -from openforms.forms.constants import SubmissionAllowedChoices +from openforms.forms.constants import LogicActionTypes, SubmissionAllowedChoices from openforms.forms.models import FormLogic from openforms.forms.validators import validate_not_deleted -from openforms.typing import JSONValue, VariableValue from openforms.utils.json_logic import partially_evaluate_json_logic from openforms.utils.urls import build_absolute_uri @@ -230,21 +230,10 @@ def to_representation(self, instance): return super().to_representation(instance) -def _to_json(value: VariableValue) -> JSONValue: - match value: - case dict(): - return {k: _to_json(v) for k, v in value.items()} - case list(): - return [_to_json(v) for v in value] - case date() | datetime() | time(): - return value.isoformat() - case _: - return value - - class FormLogicFrontendSerializer(OrderedModelSerializer): """Represent logic rules which can be executed in the frontend.""" + # Note that both fields are processed in the `to_representation` method. actions = LogicComponentActionSerializer( read_only=True, many=True, @@ -253,12 +242,14 @@ class FormLogicFrontendSerializer(OrderedModelSerializer): "Actions triggered when the trigger expression evaluates to 'truthy'." ), ) - json_logic_trigger = serializers.SerializerMethodField( + json_logic_trigger = serializers.JSONField( + read_only=True, label=_("JSON Logic Trigger"), help_text=_( "The trigger expression to determine if the actions should execute. Will " "be (partially) evaluated based on available data." ), + encoder=DjangoJSONEncoder, ) class Meta: @@ -268,8 +259,7 @@ class Meta: "actions", ) - @extend_schema_field(serializers.JSONField) - def get_json_logic_trigger(self, instance) -> JSONValue: + def to_representation(self, instance): step = self.context["submission_step"] state = step.submission.load_submission_value_variables_state() # We include all data, to make sure we have a (empty) value for every variable. @@ -282,14 +272,24 @@ def get_json_logic_trigger(self, instance) -> JSONValue: for key in state.get_variables_in_submission_step(step).keys(): data.pop(key) - # Add data type information before partially evaluating - otherwise we lose - # the variable context. + # Process action + for action in instance.actions: + if action["action"]["type"] != LogicActionTypes.variable: + continue + + value = add_data_type_information(action["action"]["value"], state) + value, _ = partially_evaluate_json_logic(value, data) + action["action"]["value"] = value + + # Process JSON logic trigger json_logic_trigger = add_data_type_information( instance.json_logic_trigger, state ) json_logic_trigger, _ = partially_evaluate_json_logic(json_logic_trigger, data) + instance.json_logic_trigger = json_logic_trigger - return _to_json(json_logic_trigger) + # Serialize + return super().to_representation(instance) class SubmissionStepSerializer(NestedHyperlinkedModelSerializer): diff --git a/src/openforms/submissions/tests/test_get_submission_step.py b/src/openforms/submissions/tests/test_get_submission_step.py index fb486d0f04..2316c632d7 100644 --- a/src/openforms/submissions/tests/test_get_submission_step.py +++ b/src/openforms/submissions/tests/test_get_submission_step.py @@ -1330,3 +1330,73 @@ def test_with_date_trigger_that_could_be_partially_resolved(self): ] } self.assertEqual(expected, response.json()["logicRules"][0]["jsonLogicTrigger"]) + + def test_variable_action_with_json_logic_expression_as_value(self): + form = FormFactory.create(new_logic_evaluation_enabled=True) + step_1 = FormStepFactory.create( + form=form, + form_definition__configuration={ + "components": [ + {"type": "textfield", "key": "isoDuration", "label": "ISO duration"} + ] + }, + ) + step_2 = FormStepFactory.create( + form=form, + form_definition__configuration={ + "components": [ + {"type": "date", "key": "dateOfBirth", "label": "Date of birth"}, + { + "type": "date", + "key": "dateOfBirthMinusDuration", + "label": "Date of birth minus duration", + }, + ] + }, + ) + # This is an actual logic rule used by municipalities! + rule = FormLogicFactory.create( + form=form, + json_logic_trigger=True, + actions=[ + { + "action": { + "type": "variable", + "value": { + "-": [ + {"var": "dateOfBirth"}, + {"duration": {"var": "isoDuration"}}, + ] + }, + }, + "variable": "dateOfBirthMinusDuration", + } + ], + ) + # Step 2 will be assigned here, because the variable action applies to + # "dateOfBirthMinusDuration". + rule.form_steps.set([step_2]) + + submission = SubmissionFactory.create(form=form) + self._add_submission_to_session(submission) + + # Simulate submitting step 1 + SubmissionStepFactory.create( + form_step=step_1, submission=submission, data={"isoDuration": "P18Y"} + ) + + # Get step 2 + url = reverse( + "api:submission-steps-detail", + kwargs={"submission_uuid": submission.uuid, "step_uuid": step_2.uuid}, + ) + response = self.client.get(url) + self.assertEqual(status.HTTP_200_OK, response.status_code) + + # The "dateOfBrith" variable should have the date operator added to it, and the + # "isoDuration" variable should be prefilled because it was already submitted + # on the previous step. + expected = {"-": [{"date": [{"var": ["dateOfBirth"]}]}, {"duration": ["P18Y"]}]} + self.assertEqual( + expected, response.json()["logicRules"][0]["actions"][0]["action"]["value"] + )