Skip to content

Commit 55e813d

Browse files
committed
🚧 [#5139] More WIP
1 parent e16ae3f commit 55e813d

File tree

7 files changed

+144
-60
lines changed

7 files changed

+144
-60
lines changed

src/openforms/formio/datastructures.py

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

163163
data: dict[str, JSONValue]
164164
_keys: set[str]
165-
updates: dict[str, JSONValue]
166165
"""
167166
A collection of flattened key names, for quicker __contains__ access
168167
"""
169168

170169
def __init__(self, *args, **kwargs):
171170
self._keys = set()
172-
self.updates = {}
173171
super().__init__(*args, **kwargs)
174172

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

src/openforms/formio/service.py

+1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def normalize_value_for_component(component: Component, value: Any) -> Any:
6161
return register.normalize(component, value)
6262

6363

64+
# TODO-5139: remove request as an argument from this
6465
@elasticapm.capture_span(span_type="app.formio")
6566
def get_dynamic_configuration(
6667
config_wrapper: FormioConfigurationWrapper,

src/openforms/submissions/form_logic.py

+75-40
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323

2424
# TODO-5139: replace `data` type with `FormioData`?
25+
# TODO-5139: replace all DataContainer type hints with FormioData
2526
@elasticapm.capture_span(span_type="app.submissions.logic")
2627
def evaluate_form_logic(
2728
submission: "Submission",
@@ -80,6 +81,7 @@ def evaluate_form_logic(
8081

8182
# 3. Load the (variables) state
8283
submission_variables_state = submission.load_submission_value_variables_state()
84+
actual_initial_data = submission_variables_state.to_python()
8385

8486
# 4. Apply the (dirty) data to the variable state.
8587
submission_variables_state.set_values(data)
@@ -94,47 +96,36 @@ def evaluate_form_logic(
9496
# 5.1 - if the action type is to set a variable, update the variable state. This
9597
# happens inside of iter_evaluate_rules. This is the ONLY operation that is allowed
9698
# to execute while we're looping through the rules.
99+
# TODO-5139: I first put this tracking of data difference inside FormioData, but
100+
# that doesn't seem like it should be its responsibility. Then yielded a complete
101+
# difference in iter_evaluate_rules, but that fails when there is no rules to be
102+
# applied: `data_diff` would be undefined. So decided to track the difference
103+
# outside iter_evaluate_rules, as we also track the mutation operations here.
104+
# Still not sure about it, though
105+
data_diff_total = FormioData()
97106
with elasticapm.capture_span(
98107
name="collect_logic_operations", span_type="app.submissions.logic"
99108
):
100-
for operation in iter_evaluate_rules(
109+
for operation, mutations in iter_evaluate_rules(
101110
rules,
102111
data_for_evaluation,
103112
submission=submission,
104113
):
105114
mutation_operations.append(operation)
106-
107-
submission_variables_state.set_values(data_for_evaluation.updates)
108-
109-
# 6. The variable state is now completely resolved - we can start processing the
110-
# dynamic configuration and side effects.
111-
112-
# Calculate which data has changed from the initial, for the 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
126-
step.data = DirtyData(updated_step_data.data)
115+
if mutations:
116+
data_diff_total.update(mutations)
127117

128118
# 7. finally, apply the dynamic configuration
129119

130120
# we need to apply the context-specific configurations before we can apply
131121
# mutations based on logic, which is then in turn passed to the serializer(s)
132-
# TODO: refactor this to rely on variables state
122+
# TODO-5139: rewrite `get_dynamic_configuration` and its callees to work with
123+
# FormioData instance
133124
config_wrapper = get_dynamic_configuration(
134125
config_wrapper,
135126
request=None,
136127
submission=submission,
137-
data=data_for_evaluation,
128+
data=data_for_evaluation.data,
138129
)
139130

140131
# 7.1 Apply the component mutation operations
@@ -145,18 +136,16 @@ def evaluate_form_logic(
145136
# (eventually) hidden BEFORE we do any further processing. This is only a bandaid
146137
# fix, as the (stale) data has potentially been input for other logic rules.
147138
# Note that only the dirty data logic check acts on these differences.
148-
149-
# only keep the changes in the data, so that old values do not overwrite otherwise
150-
# debounced client-side data changes
151-
data_diff = FormioData()
152139
for component in config_wrapper:
153140
key = component["key"]
154-
# TODO-5139: is_visible_in_frontend must accept a FormioData instance instead
141+
# TODO-5139: rewrite `is_visible_in_frontend` and its callees to work with
142+
# FormioData instance
155143
is_visible = config_wrapper.is_visible_in_frontend(key, data_for_evaluation.data)
156144
if is_visible:
157145
continue
158146

159-
# Reset the value of any field that may have become hidden again after evaluating the logic
147+
# Reset the value of any field that may have become hidden again after
148+
# evaluating the logic
160149
original_value = initial_data.get(key, empty)
161150
empty_value = get_component_empty_value(component)
162151
if original_value is empty or original_value == empty_value:
@@ -166,32 +155,75 @@ def evaluate_form_logic(
166155
continue
167156

168157
# clear the value
169-
data_for_evaluation.update({key: empty_value})
170-
data_diff[key] = empty_value
158+
data_for_evaluation[key] = empty_value
159+
data_diff_total[key] = empty_value
171160

172161
# 7.2 Interpolate the component configuration with the variables.
162+
# TODO-5139: rewrite `inject_variables` and its callees to work with FormioData
163+
# instance
173164
inject_variables(config_wrapper, data_for_evaluation.data)
174165

175-
# 7.3 Handle custom formio types - TODO: this needs to be lifted out of
176-
# :func:`get_dynamic_configuration` so that it can use variables.
166+
167+
# ---------------------------------------------------------------------------------------
168+
169+
170+
# 8. All the processing is now complete, so we can update the state.
171+
# TODO-5139: when setting a value to SubmissionValueVariable.value, it doesn't
172+
# automatically get encoded to JSON with the encoder. Probably only happens when
173+
# saving the model. This means we have to do it manually here or in
174+
# SubmissionValueVariablesState.set_values. Or do we want to always convert to
175+
# python objects, like already suggested in
176+
# SubmissionValueVariableState.set_values? Issue 2324 is related to this
177+
submission_variables_state.set_values(data_diff_total)
178+
179+
# 8.1 build a difference in data for the step. It is important that we only keep the
180+
# changes in the data, so that old values do not overwrite otherwise debounced
181+
# client-side data changes
182+
183+
# Calculate which data has changed from the initial, for the step.
184+
relevant_variables = submission_variables_state.get_variables_in_submission_step(
185+
step, include_unsaved=True
186+
)
187+
188+
# TODO-5139: the problem is that currently `variable.value` can be a json or python
189+
# object, as `iter_evaluate_rules` now returns python objects. This means that a
190+
# comparison with `initial_value` will fail, because it's a json value. We could
191+
# perform this comparison in the python-type domain, but that would mean that
192+
# invalid data will not be in step.data. Is this a bad thing?
193+
updated_step_data = FormioData()
194+
for key, variable in relevant_variables.items():
195+
if (
196+
not variable.form_variable
197+
or variable.value != variable.form_variable.initial_value
198+
):
199+
updated_step_data[key] = variable.value
200+
step.data = DirtyData(updated_step_data.data)
177201

178202
# process the output for logic checks with dirty data
179203
if dirty:
180-
# Iterate over all components instead of `step.data`, to take hidden fields into account (See: #1755)
204+
data_diff_dirty = FormioData()
205+
# Iterate over all components instead of `step.data`, to take hidden fields into
206+
# account (See: #1755)
181207
for component in config_wrapper:
182208
key = component["key"]
183209
# already processed, don't process it again
184-
if data_diff.get(key, default=empty) is not empty:
210+
if data_diff_dirty.get(key, default=empty) is not empty:
185211
continue
186212

213+
# TODO-5139: currently, this might return a python object (because we pass
214+
# python objects to the SubmissionValueVariables.set_values) whereas
215+
# previously it would return a json value. Not sure if this breaks
216+
# anything...
187217
new_value = updated_step_data.get(key, default=empty)
218+
# TODO-5139: previously these were python objects, and they still are, so
219+
# that's ok. Or is it?
188220
original_value = initial_data.get(key, default=empty)
189221
if new_value is empty or new_value == original_value:
190222
continue
191-
data_diff[key] = new_value
223+
data_diff_dirty[key] = new_value
192224

193225
# only return the 'overrides'
194-
step.data = DirtyData(data_diff.data)
226+
step.data = DirtyData(data_diff_dirty.data)
195227

196228
step._form_logic_evaluated = True
197229

@@ -220,10 +252,13 @@ def check_submission_logic(
220252
data_for_evaluation = submission_variables_state.to_python()
221253

222254
mutation_operations: list[ActionOperation] = []
223-
for operation in iter_evaluate_rules(rules, data_for_evaluation, submission):
255+
data_diff = FormioData({})
256+
for operation, mutations in iter_evaluate_rules(rules, data_for_evaluation, submission):
224257
mutation_operations.append(operation)
258+
if mutations:
259+
data_diff.update(mutations)
225260

226-
submission_variables_state.set_values(data_for_evaluation.updates)
261+
submission_variables_state.set_values(data_diff)
227262

228263
# we loop over all steps because we have validations that ensure unique component
229264
# keys across multiple steps for the whole form.

src/openforms/submissions/logic/actions.py

+1-5
Original file line numberDiff line numberDiff line change
@@ -175,12 +175,8 @@ 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]
180178
with log_errors(self.value, self.rule):
181-
result = jsonLogic(self.value, context)
182-
value_variable.to_python(result)
183-
return {self.variable: value_variable.to_python(result)}
179+
return {self.variable: jsonLogic(self.value, context)}
184180

185181

186182
@dataclass

src/openforms/submissions/logic/rules.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Iterable, Iterator
22

33
import elasticapm
4+
from glom import mutation
45
from json_logic import jsonLogic
56

67
from openforms.formio.datastructures import FormioData
@@ -110,7 +111,7 @@ def iter_evaluate_rules(
110111
rules: Iterable[FormLogic],
111112
data_container: FormioData,
112113
submission: Submission,
113-
) -> Iterator[ActionOperation]:
114+
) -> Iterator[tuple[ActionOperation, dict]]:
114115
"""
115116
Iterate over the rules and evaluate the trigger, yielding action operations.
116117
@@ -128,6 +129,7 @@ def iter_evaluate_rules(
128129
sole argument. Useful to gather metadata about rule evaluation.
129130
:returns: An iterator yielding :class:`ActionOperation` instances.
130131
"""
132+
state = submission.load_submission_value_variables_state()
131133
for rule in rules:
132134
with elasticapm.capture_span(
133135
"evaluate_rule",
@@ -147,8 +149,12 @@ def iter_evaluate_rules(
147149
if mutations := operation.eval(
148150
data_container, submission=submission
149151
):
150-
# TODO-5139: not sure if FormioData should track the changes, seems
151-
# like a task for iter_evaluate_rules itself.
152-
data_container.update(mutations)
153-
data_container.track_updates(mutations)
154-
yield operation
152+
mutations_python = {
153+
key: state.variables[key].to_python(value)
154+
for key, value in mutations.items()
155+
}
156+
data_container.update(mutations_python)
157+
else:
158+
mutations_python = {}
159+
160+
yield operation, mutations_python

src/openforms/submissions/models/submission_value_variable.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ def save_prefill_data(self, data: dict[str, Any]) -> None:
233233

234234
SubmissionValueVariable.objects.bulk_create(variables_to_create)
235235

236-
def set_values(self, data: DataMapping) -> None:
236+
def set_values(self, data: FormioData | DataMapping) -> None:
237237
"""
238238
Apply the values from ``data`` to the current state of the variables.
239239
@@ -246,7 +246,7 @@ def set_values(self, data: DataMapping) -> None:
246246
.. todo:: apply variable.datatype/format to obtain python objects? This also
247247
needs to properly serialize back to JSON though!
248248
"""
249-
formio_data = FormioData(data)
249+
formio_data = data if isinstance(data, FormioData) else FormioData(data)
250250
for key, variable in self.variables.items():
251251
new_value = formio_data.get(key, default=empty)
252252
if new_value is empty:
@@ -377,7 +377,7 @@ class Meta:
377377
def __str__(self):
378378
return _("Submission value variable {key}").format(key=self.key)
379379

380-
def to_python(self, value: JSONValue = None) -> Any:
380+
def to_python(self, value: JSONValue = empty) -> Any:
381381
"""
382382
Deserialize the value into the appropriate python type.
383383
@@ -387,7 +387,7 @@ def to_python(self, value: JSONValue = None) -> Any:
387387
to correctly interpret the data. For the time being, this is not needed yet
388388
as we focus on NL first.
389389
"""
390-
if value is None:
390+
if value is empty:
391391
value = self.value
392392

393393
if value is None:

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

+51
Original file line numberDiff line numberDiff line change
@@ -1121,6 +1121,57 @@ def test_component_value_set_to_today(self):
11211121
new_value = response.json()["step"]["data"]["date"]
11221122
self.assertEqual(new_value, "2024-03-18")
11231123

1124+
def test_date_field_not_present_in_step_data_when_assigned_same_value(self):
1125+
"""
1126+
Assert that the 'date' field is not present in the step data when a logic action
1127+
sets it to the same value. This illustrates the problem that occurs when setting
1128+
python objects directly to SubmissionValueVariable.value.
1129+
"""
1130+
form = FormFactory.create()
1131+
form_step = FormStepFactory.create(
1132+
form=form,
1133+
form_definition__configuration={
1134+
"components": [
1135+
{
1136+
"type": "date",
1137+
"key": "date",
1138+
"defaultValue": "2025-01-01"
1139+
},
1140+
]
1141+
},
1142+
)
1143+
1144+
FormLogicFactory.create(
1145+
form=form,
1146+
json_logic_trigger=True,
1147+
actions=[
1148+
{
1149+
"variable": "date",
1150+
"action": {
1151+
"type": "variable",
1152+
"value": "2025-01-01",
1153+
},
1154+
}
1155+
],
1156+
)
1157+
1158+
submission = SubmissionFactory.create(form=form)
1159+
1160+
self._add_submission_to_session(submission)
1161+
logic_check_endpoint = reverse(
1162+
"api:submission-steps-logic-check",
1163+
kwargs={
1164+
"submission_uuid": submission.uuid,
1165+
"step_uuid": form_step.uuid,
1166+
},
1167+
)
1168+
response = self.client.post(logic_check_endpoint, {"data": {}})
1169+
1170+
data = response.json()
1171+
1172+
# The date hasn't changed, so it should not be present in the step data
1173+
self.assertNotIn("date", data["step"]["data"])
1174+
11241175

11251176
def is_valid_expression(expr: dict):
11261177
try:

0 commit comments

Comments
 (0)