Skip to content

Commit

Permalink
[#72] Added backend validation for specific components
Browse files Browse the repository at this point in the history
This commit handles the backend validation for the following components:
- textarea
- time
- postcode
- bsn (11-check)
- select
- checkbox
- currency
- signature
- map
  • Loading branch information
vaszig committed Mar 21, 2024
1 parent 63ab6e5 commit 69cdcc9
Show file tree
Hide file tree
Showing 14 changed files with 663 additions and 46 deletions.
69 changes: 56 additions & 13 deletions src/openforms/formio/components/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from datetime import date
from typing import Protocol

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

Expand All @@ -14,6 +14,7 @@
from openforms.submissions.models import Submission
from openforms.typing import DataMapping
from openforms.utils.date import format_date_value
from openforms.utils.validators import BSNValidator
from openforms.validations.service import PluginValidator

from ..dynamic_config.date import mutate as mutate_min_max_validation
Expand All @@ -25,12 +26,20 @@
)
from ..formatters.formio import DefaultFormatter, TextFieldFormatter
from ..registry import BasePlugin, register
from ..typing import Component, DateComponent, DatetimeComponent
from ..typing import (
BSNComponent,
Component,
DateComponent,
DatetimeComponent,
MapComponent,
PostcodeComponent,
)
from ..utils import conform_to_mask
from .np_family_members.constants import FamilyMembersDataAPIChoices
from .np_family_members.haal_centraal import get_np_family_members_haal_centraal
from .np_family_members.models import FamilyMembersTypeConfig
from .np_family_members.stuf_bg import get_np_family_members_stuf_bg
from .utils import _normalize_pattern

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -63,7 +72,7 @@ def build_serializer_field(
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
# relevant validators: required, datePicker.minDate and datePicker.maxDate
multiple = component.get("multiple", False)
validate = component.get("validate", {})
required = validate.get("required", False)
Expand Down Expand Up @@ -98,7 +107,7 @@ def mutate_config_dynamically(


@register("map")
class Map(BasePlugin):
class Map(BasePlugin[MapComponent]):
formatter = MapFormatter

@staticmethod
Expand All @@ -111,9 +120,18 @@ def rewrite_for_request(component, request: Request):
component["initialCenter"]["lat"] = config.form_map_default_latitude
component["initialCenter"]["lng"] = config.form_map_default_longitude

def build_serializer_field(self, component: MapComponent) -> serializers.ListField:
validate = component.get("validate", {})
required = validate.get("required", False)
base = serializers.FloatField(
required=required,
allow_null=not required,
)
return serializers.ListField(child=base)


@register("postcode")
class Postcode(BasePlugin):
class Postcode(BasePlugin[PostcodeComponent]):
formatter = TextFieldFormatter

@staticmethod
Expand All @@ -133,6 +151,36 @@ def normalizer(component: Component, value: str) -> str:
)
return value

def build_serializer_field(
self, component: PostcodeComponent
) -> serializers.CharField | serializers.ListField:
multiple = component.get("multiple", False)
validate = component.get("validate", {})
required = validate.get("required", False)
# dynamically add in more kwargs based on the component configuration
extra = {}
validators = []
# adding in the validator is more explicit than changing to serialiers.RegexField,
# which essentially does the same.
if pattern := validate.get("pattern"):
validators.append(
RegexValidator(
_normalize_pattern(pattern),
message=_("This value does not match the required pattern."),
)
)

if plugin_ids := validate.get("plugins", []):
validators += [PluginValidator(plugin) for plugin in plugin_ids]

if validators:
extra["validators"] = validators

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


class FamilyMembersHandler(Protocol):
def __call__(
Expand Down Expand Up @@ -223,7 +271,7 @@ def mutate_config_dynamically(


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

def build_serializer_field(
Expand All @@ -235,17 +283,12 @@ def build_serializer_field(

# 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

validators = []
validators = [BSNValidator()]
if plugin_ids := validate.get("plugins", []):
validators += [PluginValidator(plugin) for plugin in plugin_ids]

if validators:
extra["validators"] = validators
extra["validators"] = validators

base = serializers.CharField(
required=required, allow_blank=not required, allow_null=False, **extra
Expand Down
9 changes: 9 additions & 0 deletions src/openforms/formio/components/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
def _normalize_pattern(pattern: str) -> str:
"""
Normalize a regex pattern so that it matches from beginning to the end of the value.
"""
if not pattern.startswith("^"):
pattern = f"^{pattern}"
if not pattern.endswith("$"):
pattern = f"{pattern}$"
return pattern
122 changes: 105 additions & 17 deletions src/openforms/formio/components/vanilla.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
adjacent custom.py module.
"""

from datetime import datetime
from typing import TYPE_CHECKING

from django.core.validators import RegexValidator
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.utils.translation import gettext_lazy as _

from rest_framework import serializers
Expand Down Expand Up @@ -42,32 +43,27 @@
from ..registry import BasePlugin, register
from ..serializers import build_serializer
from ..typing import (
CheckboxComponent,
Component,
ContentComponent,
CurrencyComponent,
EditGridComponent,
FileComponent,
RadioComponent,
SelectBoxesComponent,
SelectComponent,
SignatureComponent,
TextAreaComponent,
TextFieldComponent,
TimeComponent,
)
from .translations import translate_options
from .utils import _normalize_pattern

if TYPE_CHECKING:
from openforms.submissions.models import Submission


def _normalize_pattern(pattern: str) -> str:
"""
Normalize a regex pattern so that it matches from beginning to the end of the value.
"""
if not pattern.startswith("^"):
pattern = f"^{pattern}"
if not pattern.endswith("$"):
pattern = f"{pattern}$"
return pattern


@register("default")
class Default(BasePlugin):
"""
Expand Down Expand Up @@ -150,9 +146,32 @@ def build_serializer_field(


@register("time")
class Time(BasePlugin):
class Time(BasePlugin[TimeComponent]):
formatter = TimeFormatter

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

validators = []
if min_time := validate.get("minTime"):
validators.append(
MinValueValidator(datetime.strptime(min_time, "%H:%M").time())
)
if max_time := validate.get("maxTime"):
validators.append(
MaxValueValidator(datetime.strptime(max_time, "%H:%M").time())
)
base = serializers.TimeField(
required=required,
allow_null=not required,
validators=validators,
)
return serializers.ListField(child=base) if multiple else base


@register("phoneNumber")
class PhoneNumber(BasePlugin):
Expand Down Expand Up @@ -222,9 +241,26 @@ def rewrite_for_request(component: FileComponent, request: Request):


@register("textarea")
class TextArea(BasePlugin):
class TextArea(BasePlugin[TextAreaComponent]):
formatter = TextAreaFormatter

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

# 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.CharField(
required=required, allow_blank=not required, allow_null=False, **extra
)
return serializers.ListField(child=base) if multiple else base


@register("number")
class Number(BasePlugin):
Expand Down Expand Up @@ -263,9 +299,16 @@ class Password(BasePlugin):


@register("checkbox")
class Checkbox(BasePlugin):
class Checkbox(BasePlugin[CheckboxComponent]):
formatter = CheckboxFormatter

def build_serializer_field(
self, component: CheckboxComponent
) -> serializers.BooleanField:
validate = component.get("validate", {})
required = validate.get("required", False)
return serializers.BooleanField(required=required)


@register("selectboxes")
class SelectBoxes(BasePlugin[SelectBoxesComponent]):
Expand Down Expand Up @@ -306,11 +349,49 @@ def localize(self, component: SelectComponent, language_code: str, enabled: bool
return
translate_options(options, language_code, enabled)

def build_serializer_field(
self, component: SelectComponent
) -> serializers.MultipleChoiceField:
validate = component.get("validate", {})
required = validate.get("required", False)
choices = [
(value["value"], value["label"]) for value in component["data"]["values"]
]
return serializers.MultipleChoiceField(
choices=choices,
required=required,
allow_blank=not required,
allow_null=not required,
)


@register("currency")
class Currency(BasePlugin):
class Currency(BasePlugin[CurrencyComponent]):
formatter = CurrencyFormatter

def build_serializer_field(
self, component: CurrencyComponent
) -> serializers.FloatField:
validate = component.get("validate", {})
required = validate.get("required", False)

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

validators = []
if plugin_ids := validate.get("plugins", []):
validators += [PluginValidator(plugin) for plugin in plugin_ids]

if validators:
extra["validators"] = validators

return serializers.FloatField(
required=required, allow_null=not required, **extra
)


@register("radio")
class Radio(BasePlugin[RadioComponent]):
Expand Down Expand Up @@ -348,9 +429,16 @@ def build_serializer_field(


@register("signature")
class Signature(BasePlugin):
class Signature(BasePlugin[SignatureComponent]):
formatter = SignatureFormatter

def build_serializer_field(
self, component: SignatureComponent
) -> serializers.CharField:
validate = component.get("validate", {})
required = validate.get("required", False)
return serializers.CharField(required=required, allow_null=not required)


@register("content")
class Content(BasePlugin):
Expand Down
Loading

0 comments on commit 69cdcc9

Please sign in to comment.