Skip to content

Commit 6050a45

Browse files
committed
🚧 [#5139] Remove DataContainer usage from iter_evaluate_rules
It's a quick test to see what happens if we were to remove the DataContainer from iter_evaluate_rules, and replace it with a FormioData instance. Also added support for FormioData in recursive_apply to fix several failing tests. test_two_actions_on_the_same_variable illustrates what happens when two actions are performed on the same variable.
1 parent 4cb4b65 commit 6050a45

File tree

8 files changed

+135
-32
lines changed

8 files changed

+135
-32
lines changed

src/openforms/formio/datastructures.py

+5
Original file line numberDiff line numberDiff line change
@@ -162,12 +162,14 @@ class FormioData(UserDict):
162162

163163
data: dict[str, JSONValue]
164164
_keys: set[str]
165+
updates: dict[str, JSONValue]
165166
"""
166167
A collection of flattened key names, for quicker __contains__ access
167168
"""
168169

169170
def __init__(self, *args, **kwargs):
170171
self._keys = set()
172+
self.updates = {}
171173
super().__init__(*args, **kwargs)
172174

173175
def __getitem__(self, key: str):
@@ -206,3 +208,6 @@ def __contains__(self, key: object) -> bool:
206208
return True
207209
except KeyError:
208210
return False
211+
212+
def track_updates(self, m: dict[str, JSONValue]) -> None:
213+
self.updates.update(m)

src/openforms/formio/utils.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ def recursive_apply(
447447
JSON serialization unless transform_leaf flag is set to True where func is
448448
applied to the nested value as well.
449449
"""
450+
from openforms.formio.datastructures import FormioData
450451
match input:
451452
# string primitive - we can throw it into the template engine
452453
case str():
@@ -460,7 +461,7 @@ def recursive_apply(
460461
]
461462

462463
# mapping - map every key/value pair recursively
463-
case dict():
464+
case dict() | FormioData():
464465
return {
465466
key: recursive_apply(nested_bit, func, transform_leaf, *args, **kwargs)
466467
for key, nested_bit in input.items()

src/openforms/submissions/form_logic.py

+25-9
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .models import Submission, SubmissionStep
2222

2323

24+
# TODO-5139: replace `data` type with `FormioData`?
2425
@elasticapm.capture_span(span_type="app.submissions.logic")
2526
def evaluate_form_logic(
2627
submission: "Submission",
@@ -84,7 +85,8 @@ def evaluate_form_logic(
8485
submission_variables_state.set_values(data)
8586

8687
rules = get_rules_to_evaluate(submission, step)
87-
data_container = DataContainer(state=submission_variables_state)
88+
initial_data = submission_variables_state.to_python()
89+
data_for_evaluation = submission_variables_state.to_python()
8890

8991
# 5. Evaluate the logic rules in order
9092
mutation_operations = []
@@ -97,16 +99,30 @@ def evaluate_form_logic(
9799
):
98100
for operation in iter_evaluate_rules(
99101
rules,
100-
data_container,
102+
data_for_evaluation,
101103
submission=submission,
102104
):
103105
mutation_operations.append(operation)
104106

107+
submission_variables_state.set_values(data_for_evaluation.updates)
108+
105109
# 6. The variable state is now completely resolved - we can start processing the
106110
# dynamic configuration and side effects.
107111

108112
# Calculate which data has changed from the initial, for the step.
109-
updated_step_data = data_container.get_updated_step_data(step)
113+
# TODO-5139: this should ideally be built up from mutation returns inside
114+
# iter_evaluate_rules
115+
relevant_variables = submission_variables_state.get_variables_in_submission_step(
116+
step, include_unsaved=True
117+
)
118+
119+
updated_step_data = FormioData()
120+
for key, variable in relevant_variables.items():
121+
if (
122+
not variable.form_variable
123+
or variable.value != variable.form_variable.initial_value
124+
):
125+
updated_step_data[key] = variable.value
110126
step.data = DirtyData(updated_step_data.data)
111127

112128
# 7. finally, apply the dynamic configuration
@@ -129,8 +145,6 @@ def evaluate_form_logic(
129145
for mutation in mutation_operations:
130146
mutation.apply(step, config_wrapper)
131147

132-
initial_data = FormioData(data_container.initial_data)
133-
134148
# XXX: See #2340 and #2409 - we need to clear the values of components that are
135149
# (eventually) hidden BEFORE we do any further processing. This is only a bandaid
136150
# fix, as the (stale) data has potentially been input for other logic rules.
@@ -155,11 +169,11 @@ def evaluate_form_logic(
155169
continue
156170

157171
# clear the value
158-
data_container.update({key: empty_value})
172+
data_for_evaluation.update({key: empty_value})
159173
data_diff[key] = empty_value
160174

161175
# 7.2 Interpolate the component configuration with the variables.
162-
inject_variables(config_wrapper, data_container.data)
176+
inject_variables(config_wrapper, data_for_evaluation.data)
163177

164178
# 7.3 Handle custom formio types - TODO: this needs to be lifted out of
165179
# :func:`get_dynamic_configuration` so that it can use variables.
@@ -206,12 +220,14 @@ def check_submission_logic(
206220
submission_variables_state = submission.load_submission_value_variables_state()
207221
if unsaved_data:
208222
submission_variables_state.set_values(unsaved_data)
209-
data_container = DataContainer(state=submission_variables_state)
223+
data_for_evaluation = submission_variables_state.to_python()
210224

211225
mutation_operations: list[ActionOperation] = []
212-
for operation in iter_evaluate_rules(rules, data_container, submission):
226+
for operation in iter_evaluate_rules(rules, data_for_evaluation, submission):
213227
mutation_operations.append(operation)
214228

229+
submission_variables_state.set_values(data_for_evaluation.updates)
230+
215231
# we loop over all steps because we have validations that ensure unique component
216232
# keys across multiple steps for the whole form.
217233
#

src/openforms/submissions/logic/actions.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,12 @@ def eval(
175175
context: DataMapping,
176176
submission: Submission,
177177
) -> DataMapping:
178+
state = submission.load_submission_value_variables_state()
179+
value_variable = state.variables[self.variable]
178180
with log_errors(self.value, self.rule):
179-
return {self.variable: jsonLogic(self.value, context)}
181+
result = jsonLogic(self.value, context)
182+
value_variable.to_python(result)
183+
return {self.variable: value_variable.to_python(result)}
180184

181185

182186
@dataclass

src/openforms/submissions/logic/datastructures.py

+11
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,24 @@ def data(self) -> DataMapping:
3737
:return: A datamapping (key: variable key, value: variable value) ready for
3838
(template context) evaluation.
3939
"""
40+
# TODO: this .to_python is needed for conversion of date/datetimes. Not sure yet
41+
# if logic evaluation uses this in any way, though. Maybe we can get rid of it.
4042
dynamic_values = {
4143
key: variable.to_python() for key, variable in self.state.variables.items()
4244
}
4345
static_values = self.state.static_data()
4446
nested_data = FormioData({**dynamic_values, **static_values})
4547
return nested_data.data
4648

49+
@property
50+
def data_without_to_python(self):
51+
dynamic_values = {
52+
key: variable.value for key, variable in self.state.variables.items()
53+
}
54+
static_values = self.state.static_data()
55+
nested_data = FormioData({**dynamic_values, **static_values})
56+
return nested_data.data
57+
4758
def update(self, updates: DataMapping) -> None:
4859
"""
4960
Update the dynamic data state.

src/openforms/submissions/logic/rules.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
import elasticapm
44
from json_logic import jsonLogic
55

6+
from openforms.formio.datastructures import FormioData
67
from openforms.forms.models import FormLogic, FormStep
78

89
from ..models import Submission, SubmissionStep
910
from .actions import ActionOperation
10-
from .datastructures import DataContainer
1111
from .log_utils import log_errors
1212

1313

@@ -108,7 +108,7 @@ def get_current_step(submission: Submission) -> SubmissionStep | None:
108108

109109
def iter_evaluate_rules(
110110
rules: Iterable[FormLogic],
111-
data_container: DataContainer,
111+
data_container: FormioData,
112112
submission: Submission,
113113
) -> Iterator[ActionOperation]:
114114
"""
@@ -137,15 +137,18 @@ def iter_evaluate_rules(
137137
triggered = False
138138
with log_errors(rule.json_logic_trigger, rule):
139139
triggered = bool(
140-
jsonLogic(rule.json_logic_trigger, data_container.data)
140+
jsonLogic(rule.json_logic_trigger, data_container)
141141
)
142142

143143
if not triggered:
144144
continue
145145

146146
for operation in rule.action_operations:
147147
if mutations := operation.eval(
148-
data_container.data, submission=submission
148+
data_container, submission=submission
149149
):
150+
# TODO-5139: not sure if FormioData should track the changes, seems
151+
# like a task for iter_evaluate_rules itself.
150152
data_container.update(mutations)
153+
data_container.track_updates(mutations)
151154
yield operation

src/openforms/submissions/models/submission_value_variable.py

+36-17
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from openforms.formio.service import FormioData
1616
from openforms.forms.models.form_variable import FormVariable
17-
from openforms.typing import DataMapping, JSONEncodable, JSONObject, JSONSerializable
17+
from openforms.typing import DataMapping, JSONEncodable, JSONObject, JSONSerializable, JSONValue
1818
from openforms.utils.date import format_date_value, parse_datetime, parse_time
1919
from openforms.variables.constants import FormVariableDataTypes
2020
from openforms.variables.service import VariablesRegistry, get_static_variables
@@ -253,6 +253,22 @@ def set_values(self, data: DataMapping) -> None:
253253
continue
254254
variable.value = new_value
255255

256+
# TODO-5139: can this be combined with SubmissionValueVariablesState.get_data?
257+
def to_python(self) -> FormioData:
258+
"""
259+
Collect the total picture of data/variable values.
260+
261+
The dynamic values are augmented with the static variables.
262+
263+
:return: A datamapping (key: variable key, value: native python object for the
264+
value) ready for (template context) evaluation.
265+
"""
266+
dynamic_values = {
267+
key: variable.to_python() for key, variable in self.variables.items()
268+
}
269+
static_values = self.static_data()
270+
return FormioData({**dynamic_values, **static_values})
271+
256272

257273
class SubmissionValueVariableManager(models.Manager):
258274
def bulk_create_or_update_from_data(
@@ -361,7 +377,7 @@ class Meta:
361377
def __str__(self):
362378
return _("Submission value variable {key}").format(key=self.key)
363379

364-
def to_python(self) -> Any:
380+
def to_python(self, value: JSONValue = None) -> Any:
365381
"""
366382
Deserialize the value into the appropriate python type.
367383
@@ -371,7 +387,10 @@ def to_python(self) -> Any:
371387
to correctly interpret the data. For the time being, this is not needed yet
372388
as we focus on NL first.
373389
"""
374-
if self.value is None:
390+
if value is None:
391+
value = self.value
392+
393+
if value is None:
375394
return None
376395

377396
# it's possible a submission value variable exists without the form variable
@@ -390,7 +409,7 @@ def to_python(self) -> Any:
390409
"submission_id": self.submission_id,
391410
},
392411
)
393-
return self.value
412+
return value
394413

395414
# we expect JSON types to have been properly stored (and thus not as string!)
396415
data_type = self.form_variable.data_type
@@ -402,37 +421,37 @@ def to_python(self) -> Any:
402421
FormVariableDataTypes.float,
403422
FormVariableDataTypes.array,
404423
):
405-
return self.value
424+
return value
406425

407-
if self.value and data_type == FormVariableDataTypes.date:
408-
if isinstance(self.value, date):
409-
return self.value
410-
formatted_date = format_date_value(self.value)
426+
if value and data_type == FormVariableDataTypes.date:
427+
if isinstance(value, date):
428+
return value
429+
formatted_date = format_date_value(value)
411430
naive_date = parse_date(formatted_date)
412431
if naive_date is not None:
413432
aware_date = timezone.make_aware(datetime.combine(naive_date, time.min))
414433
return aware_date.date()
415434

416-
maybe_naive_datetime = parse_datetime(self.value)
435+
maybe_naive_datetime = parse_datetime(value)
417436
if maybe_naive_datetime is None:
418437
return
419438

420439
if timezone.is_aware(maybe_naive_datetime):
421440
return maybe_naive_datetime.date()
422441
return timezone.make_aware(maybe_naive_datetime).date()
423442

424-
if self.value and data_type == FormVariableDataTypes.datetime:
425-
if isinstance(self.value, datetime):
426-
return self.value
427-
maybe_naive_datetime = parse_datetime(self.value)
443+
if value and data_type == FormVariableDataTypes.datetime:
444+
if isinstance(value, datetime):
445+
return value
446+
maybe_naive_datetime = parse_datetime(value)
428447
if maybe_naive_datetime is None:
429448
return
430449

431450
if timezone.is_aware(maybe_naive_datetime):
432451
return maybe_naive_datetime
433452
return timezone.make_aware(maybe_naive_datetime)
434453

435-
if self.value and data_type == FormVariableDataTypes.time:
436-
return parse_time(self.value)
454+
if value and data_type == FormVariableDataTypes.time:
455+
return parse_time(value)
437456

438-
return self.value
457+
return value

src/openforms/submissions/tests/form_logic/test_modify_variables.py

+44
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,50 @@
1919

2020

2121
class VariableModificationTests(TestCase):
22+
def test_two_actions_on_the_same_variable(self):
23+
form = FormFactory.create()
24+
form_step = FormStepFactory.create(
25+
form=form,
26+
form_definition__configuration={
27+
"components": [
28+
{"type": "date", "key": "date"},
29+
]
30+
},
31+
)
32+
submission = SubmissionFactory.create(form=form)
33+
submission_step = SubmissionStepFactory.create(
34+
submission=submission,
35+
form_step=form_step,
36+
data={},
37+
)
38+
39+
FormLogicFactory.create(
40+
form=form,
41+
json_logic_trigger=True,
42+
actions=[
43+
{
44+
"variable": "date",
45+
"action": {
46+
"type": "variable",
47+
"value": "2025-06-06",
48+
},
49+
},
50+
{
51+
"variable": "date",
52+
"action": {
53+
"type": "variable",
54+
"value": {"+": [{"var": "date"}, {"duration": "P1M"}]},
55+
},
56+
}
57+
],
58+
)
59+
60+
evaluate_form_logic(submission, submission_step, submission.data)
61+
62+
variables_state = submission.load_submission_value_variables_state()
63+
64+
self.assertEqual(str(variables_state.variables["date"].value), "2025-07-06")
65+
2266
def test_modify_variable_related_to_step_being_edited(self):
2367
form = FormFactory.create()
2468
step1 = FormStepFactory.create(

0 commit comments

Comments
 (0)