Skip to content

Commit

Permalink
Merge pull request #3994 from open-formulieren/feature/3977-serialize…
Browse files Browse the repository at this point in the history
…r-field-from-formio-component

Implement formio form-to-serializer translation
  • Loading branch information
sergei-maertens authored Mar 12, 2024
2 parents a0369f1 + 7a1587a commit 2db2d19
Show file tree
Hide file tree
Showing 20 changed files with 1,013 additions and 27 deletions.
56 changes: 56 additions & 0 deletions src/openforms/formio/components/custom.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import logging
from datetime import date
from typing import Protocol

from django.core.validators import MaxValueValidator, MinValueValidator
from django.utils.html import format_html
from django.utils.translation import gettext as _

from rest_framework import serializers
from rest_framework.request import Request

from openforms.authentication.constants import AuthAttribute
Expand Down Expand Up @@ -50,6 +53,32 @@ def mutate_config_dynamically(
"""
mutate_min_max_validation(component, data)

def build_serializer_field(
self, component: DateComponent
) -> serializers.DateField | serializers.ListField:
"""
Accept date values.
Additional validation is taken from the datePicker configuration, which is also
set dynamically through our own backend (see :meth:`mutate_config_dynamically`).
"""
# relevant validators: required, datePicker.minDate and datePicker.maxDdate
multiple = component.get("multiple", False)
validate = component.get("validate", {})
required = validate.get("required", False)
date_picker = component.get("datePicker") or {}
validators = []
if min_date := date_picker.get("minDate"):
validators.append(MinValueValidator(date.fromisoformat(min_date)))
if max_date := date_picker.get("maxDate"):
validators.append(MaxValueValidator(date.fromisoformat(max_date)))
base = serializers.DateField(
required=required,
allow_null=not required,
validators=validators,
)
return serializers.ListField(child=base) if multiple else base


@register("datetime")
class Datetime(BasePlugin):
Expand Down Expand Up @@ -192,6 +221,33 @@ def mutate_config_dynamically(
]


@register("bsn")
class BSN(BasePlugin):
formatter = TextFieldFormatter

def build_serializer_field(
self, component: Component
) -> serializers.CharField | serializers.ListField:
multiple = component.get("multiple", False)
validate = component.get("validate", {})
required = validate.get("required", False)

if validate.get("plugins", []):
raise NotImplementedError("Plugin validators not supported yet.")

# dynamically add in more kwargs based on the component configuration
extra = {}
# maxLength because of the usage in appointments, even though our form builder
# does not expose it. See `openforms.appointments.contrib.qmatic.constants`.
if (max_length := validate.get("maxLength")) is not None:
extra["max_length"] = max_length

base = serializers.CharField(
required=required, allow_blank=not required, allow_null=False, **extra
)
return serializers.ListField(child=base) if multiple else base


@register("addressNL")
class AddressNL(BasePlugin):

Expand Down
164 changes: 164 additions & 0 deletions src/openforms/formio/components/vanilla.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

from typing import TYPE_CHECKING

from django.core.validators import 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

Expand Down Expand Up @@ -35,8 +39,11 @@
TimeFormatter,
)
from ..registry import BasePlugin, register
from ..serializers import build_serializer
from ..typing import (
Component,
ContentComponent,
EditGridComponent,
FileComponent,
RadioComponent,
SelectBoxesComponent,
Expand All @@ -62,11 +69,64 @@ class Default(BasePlugin):
class TextField(BasePlugin[TextFieldComponent]):
formatter = TextFieldFormatter

def build_serializer_field(
self, component: TextFieldComponent
) -> serializers.CharField | serializers.ListField:
multiple = component.get("multiple", False)
validate = component.get("validate", {})
required = validate.get("required", False)

if validate.get("plugins", []):
raise NotImplementedError("Plugin validators not supported yet.")

# dynamically add in more kwargs based on the component configuration
extra = {}
if (max_length := validate.get("maxLength")) is not None:
extra["max_length"] = max_length

# adding in the validator is more explicit than changing to serialiers.RegexField,
# which essentially does the same.
validators = []
if pattern := validate.get("pattern"):
validators.append(
RegexValidator(
pattern,
message=_("This value does not match the required pattern."),
)
)
if validators:
extra["validators"] = validators

base = serializers.CharField(
required=required, allow_blank=not required, allow_null=False, **extra
)
return serializers.ListField(child=base) if multiple else base


@register("email")
class Email(BasePlugin):
formatter = EmailFormatter

def build_serializer_field(
self, component: Component
) -> serializers.EmailField | serializers.ListField:
multiple = component.get("multiple", False)
validate = component.get("validate", {})
required = validate.get("required", False)

if validate.get("plugins", []):
raise NotImplementedError("Plugin validators not supported yet.")

# dynamically add in more kwargs based on the component configuration
extra = {}
if (max_length := validate.get("maxLength")) is not None:
extra["max_length"] = max_length

base = serializers.EmailField(
required=required, allow_blank=not required, allow_null=False, **extra
)
return serializers.ListField(child=base) if multiple else base


@register("time")
class Time(BasePlugin):
Expand All @@ -77,6 +137,41 @@ class Time(BasePlugin):
class PhoneNumber(BasePlugin):
formatter = PhoneNumberFormatter

def build_serializer_field(
self, component: Component
) -> serializers.CharField | serializers.ListField:
multiple = component.get("multiple", False)
validate = component.get("validate", {})
required = validate.get("required", False)

if validate.get("plugins", []):
raise NotImplementedError("Plugin validators not supported yet.")

# dynamically add in more kwargs based on the component configuration
extra = {}
# maxLength because of the usage in appointments, even though our form builder
# does not expose it. See `openforms.appointments.contrib.qmatic.constants`.
if (max_length := validate.get("maxLength")) is not None:
extra["max_length"] = max_length

# adding in the validator is more explicit than changing to serialiers.RegexField,
# which essentially does the same.
validators = []
if pattern := validate.get("pattern"):
validators.append(
RegexValidator(
pattern,
message=_("This value does not match the required pattern."),
)
)
if validators:
extra["validators"] = validators

base = serializers.CharField(
required=required, allow_blank=not required, allow_null=False, **extra
)
return serializers.ListField(child=base) if multiple else base


@register("file")
class File(BasePlugin[FileComponent]):
Expand Down Expand Up @@ -112,6 +207,30 @@ class TextArea(BasePlugin):
class Number(BasePlugin):
formatter = NumberFormatter

def build_serializer_field(
self, component: Component
) -> serializers.FloatField | serializers.ListField:
# new builder no longer exposes this, but existing forms may have multiple set
multiple = component.get("multiple", False)
validate = component.get("validate", {})
required = validate.get("required", False)

if validate.get("plugins", []):
raise NotImplementedError("Plugin validators not supported yet.")

extra = {}
if max_value := validate.get("max"):
extra["max_value"] = max_value
if min_value := validate.get("min"):
extra["min_value"] = min_value

base = serializers.FloatField(
required=required,
allow_null=not required,
**extra,
)
return serializers.ListField(child=base) if multiple else base


@register("password")
class Password(BasePlugin):
Expand Down Expand Up @@ -182,6 +301,26 @@ def localize(self, component: RadioComponent, language_code: str, enabled: bool)
return
translate_options(options, language_code, enabled)

def build_serializer_field(
self, component: RadioComponent
) -> serializers.ChoiceField:
"""
Convert a radio component to a serializer field.
A radio component allows only a single value to be selected, but selecting a
value may not be required. The available choices are taken from the ``values``
key, which may be set dynamically (see :meth:`mutate_config_dynamically`).
"""
validate = component.get("validate", {})
required = validate.get("required", False)
choices = [(value["value"], value["label"]) for value in component["values"]]
return serializers.ChoiceField(
choices=choices,
required=required,
allow_blank=not required,
allow_null=not required,
)


@register("signature")
class Signature(BasePlugin):
Expand Down Expand Up @@ -210,3 +349,28 @@ def rewrite_for_request(component: ContentComponent, request: Request):
security risk.
"""
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", []),
# XXX: check out type annotations here, there's some co/contra variance
# in play
register=self.registry,
)
kwargs = {}
if (max_length := validate.get("maxLength")) is not None:
kwargs["max_length"] = max_length
return serializers.ListField(
child=nested,
required=required,
allow_null=not required,
allow_empty=False,
**kwargs,
)
36 changes: 36 additions & 0 deletions src/openforms/formio/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
the public API better defined and smaller.
"""

import warnings
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar

from django.utils.translation import gettext as _

from rest_framework import serializers
from rest_framework.request import Request

from openforms.plugins.plugin import AbstractBasePlugin
Expand Down Expand Up @@ -78,6 +80,26 @@ def mutate_config_dynamically(
def localize(self, component: ComponentT, language_code: str, enabled: bool):
pass # noop by default, specific component types can extend the base behaviour

def build_serializer_field(self, component: ComponentT) -> serializers.Field:
# the default implementation is a compatibility shim while we transition to
# the new backend validation mechanism.
warnings.warn(
"Relying on the default/implicit JSONField for component type "
f"{component['type']} is deprecated. Instead, define the "
"'build_serializer_field' method on the specific component plugin.",
DeprecationWarning,
)

required = (
validate.get("required", False)
if (validate := component.get("validate"))
else False
)

# Allow anything that is valid JSON, taking into account the 'required'
# validation which is common for most components.
return serializers.JSONField(required=required, allow_null=True)


class ComponentRegistry(BaseRegistry[BasePlugin]):
module = "formio_components"
Expand Down Expand Up @@ -171,6 +193,20 @@ def localize_component(
if generic_translations:
del component["openForms"]["translations"] # type: ignore

def build_serializer_field(self, component: Component) -> serializers.Field:
"""
Translate a given component into a single serializer field, suitable for
input validation.
"""
# if the component known in registry -> use the component plugin, otherwise
# fall back to the special 'default' plugin which implements the current
# behaviour of accepting any JSON value.
if (component_type := component["type"]) not in self:
component_type = "default"

component_plugin = self[component_type]
return component_plugin.build_serializer_field(component)


# Sentinel to provide the default registry. You can easily instantiate another
# :class:`Registry` object to use as dependency injection in tests.
Expand Down
Loading

0 comments on commit 2db2d19

Please sign in to comment.