Skip to content

Commit

Permalink
🐛 [#4126] Rework editgrid serializer field setup
Browse files Browse the repository at this point in the history
We cannot simply use ListField, as that applies the same
child serializer for every item inside the array of values,
but the field configuration of the child actually changes
depending on the values passed in (some fields may be
hidden, and that disables their validations entirely).

Note the disclaimer about rest_framework.fields.Field.__repr__,
this method behaves unexpectedly because it doesn't report
the current values of args/kwargs, but the values at the
time of instance creation.

Backport-of: #4125
  • Loading branch information
sergei-maertens committed Apr 8, 2024
1 parent 8b36990 commit c4ebb17
Show file tree
Hide file tree
Showing 3 changed files with 327 additions and 17 deletions.
116 changes: 104 additions & 12 deletions src/openforms/formio/components/vanilla.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@
adjacent custom.py module.
"""

from collections.abc import Mapping
from typing import TYPE_CHECKING

from django.core.validators import RegexValidator
from django.core.validators import (
MaxLengthValidator,
MinLengthValidator,
RegexValidator,
)
from django.utils.translation import gettext_lazy as _

from rest_framework import serializers
from rest_framework.request import Request
from rest_framework.reverse import reverse
from rest_framework.utils.formatting import lazy_format

from csp_post_processor import post_process_html
from openforms.config.constants import UploadFileType
Expand All @@ -21,6 +27,7 @@
from openforms.utils.urls import build_absolute_uri
from openforms.validations.service import PluginValidator

from ..datastructures import FormioData
from ..dynamic_config.dynamic_options import add_options_to_config
from ..formatters.formio import (
CheckboxFormatter,
Expand Down Expand Up @@ -388,24 +395,109 @@ def rewrite_for_request(component: ContentComponent, request: Request):
component["html"] = post_process_html(component["html"], request)


@register("editgrid")
class EditGrid(BasePlugin[EditGridComponent]):
def build_serializer_field(
self, component: EditGridComponent
) -> serializers.ListField:
validate = component.get("validate", {})
required = validate.get("required", False)
nested = build_serializer(
components=component.get("components", []),
class EditGridField(serializers.Field):
"""
A variant of :class:`serializers.ListField`.
The same child serializer cannot be applied for each item in the list of values,
since the field validation parameters depend on the input data/conditionals.
For each item, a dynamic serializer is set up on the fly to perform input
validation.
"""

initial = []
default_error_messages = {
"not_a_list": _('Expected a list of items but got type "{input_type}".'),
"empty": _("This list may not be empty."),
"min_length": _("Ensure this field has at least {min_length} elements."),
"max_length": _("Ensure this field has no more than {max_length} elements."),
}

def __init__(self, **kwargs):
self.registry = kwargs.pop("registry")
self.components: list[Component] = kwargs.pop("components", [])
self.allow_empty = kwargs.pop("allow_empty", True)
self.max_length = kwargs.pop("max_length", None)
self.min_length = kwargs.pop("min_length", None)
super().__init__(**kwargs)
if self.max_length is not None:
message = lazy_format(
self.error_messages["max_length"], max_length=self.max_length
)
self.validators.append(MaxLengthValidator(self.max_length, message=message))
if self.min_length is not None:
message = lazy_format(
self.error_messages["min_length"], min_length=self.min_length
)
self.validators.append(MinLengthValidator(self.min_length, message=message))

def get_value(self, dictionary):
# We don't bother with html or partial serializers
return dictionary.get(self.field_name, serializers.empty)

def to_internal_value(self, data):
"""
List of dicts of native values <- List of dicts of primitive datatypes.
"""
if isinstance(data, (str, Mapping)) or not hasattr(data, "__iter__"):
self.fail("not_a_list", input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
self.fail("empty")
return self.run_child_validation(data)

def to_representation(self, value: list):
"""
List of object instances -> List of dicts of primitive datatypes.
"""
child = self._build_child()
return [
child.to_representation(item) if item is not None else None
for item in value
]

def _build_child(self, **kwargs):
return build_serializer(
components=self.components,
# XXX: check out type annotations here, there's some co/contra variance
# in play
register=self.registry,
**kwargs,
)

def run_child_validation(self, data):
result = []
errors = {}

for idx, item in enumerate(data):
# given the local scope of data, build a nested serializer for the component
# configuration and apply the dynamic hide/visible logic to it.
# Note that we add the editgrid key as container so that the
# conditional.when values resolve, as these look like `editgridparent.child`.
data = FormioData({**self.root.initial_data, self.field_name: item}).data
nested_serializer = self._build_child(data=data)
try:
result.append(nested_serializer.run_validation(item))
except serializers.ValidationError as e:
errors[idx] = e.detail

if not errors:
return result
raise serializers.ValidationError(errors)


@register("editgrid")
class EditGrid(BasePlugin[EditGridComponent]):
def build_serializer_field(self, component: EditGridComponent) -> EditGridField:
validate = component.get("validate", {})
required = validate.get("required", False)
components = component.get("components", [])
kwargs = {}
if (max_length := validate.get("maxLength")) is not None:
kwargs["max_length"] = max_length
return serializers.ListField(
child=nested,
return EditGridField(
components=components,
registry=self.registry,
required=required,
allow_null=not required,
allow_empty=not required,
Expand Down
9 changes: 7 additions & 2 deletions src/openforms/formio/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ def apply_hidden_state(
self._remove_validations_from_field(serializer_field)

def _remove_validations_from_field(self, field: serializers.Field) -> None:
from .components.vanilla import EditGridField # circular import

# Dynamically change the properties of the field to remove validations. CAREFUL
# when inspecting field instances with a debugger - ``str(field)`` or
# ``repr(field)`` does *not* reflect the current state of field, only how it
# was initialized. Mutations are not taken into account here (!!).
field.required = False
# note that prefilled fields are validated separately when they're read-only
# to protect against tampering
Expand All @@ -81,8 +87,7 @@ def _remove_validations_from_field(self, field: serializers.Field) -> None:
case serializers.CharField():
field.allow_blank = True

case serializers.ListField():
field.allow_null = True
case serializers.ListField() | EditGridField():
field.allow_empty = True
field.min_length = None
field.max_length = None
Expand Down
Loading

0 comments on commit c4ebb17

Please sign in to comment.