Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement backend validation for formio datetime component #4311

Merged
merged 5 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions requirements/ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ glom==20.11.0
# -c requirements/base.txt
# -r requirements/base.txt
# mozilla-django-oidc-db
greenlet==2.0.2
greenlet==3.0.3
# via playwright
html5lib==1.1
# via
Expand Down Expand Up @@ -688,7 +688,7 @@ platformdirs==2.2.0
# -r requirements/base.txt
# black
# zeep
playwright==1.38.0
playwright==1.44.0
# via -r requirements/test-tools.in
pluggy==0.13.1
# via pytest
Expand Down Expand Up @@ -733,7 +733,7 @@ pydyf==0.8.0
# -c requirements/base.txt
# -r requirements/base.txt
# weasyprint
pyee==9.0.4
pyee==11.1.0
# via playwright
pyflakes==3.0.1
# via flake8
Expand Down
6 changes: 3 additions & 3 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ glom==20.11.0
# mozilla-django-oidc-db
gprof2dot==2021.2.21
# via django-silk
greenlet==2.0.2
greenlet==3.0.3
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
Expand Down Expand Up @@ -779,7 +779,7 @@ platformdirs==2.2.0
# -r requirements/ci.txt
# black
# zeep
playwright==1.38.0
playwright==1.44.0
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
Expand Down Expand Up @@ -835,7 +835,7 @@ pydyf==0.8.0
# -c requirements/ci.txt
# -r requirements/ci.txt
# weasyprint
pyee==9.0.4
pyee==11.1.0
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
Expand Down
12 changes: 6 additions & 6 deletions src/openforms/conf/locale/nl/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Open Forms\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-05-21 13:43+0200\n"
"POT-Creation-Date: 2024-05-22 10:57+0200\n"
"PO-Revision-Date: 2024-05-17 15:22+0200\n"
"Last-Translator: Sergei Maertens <sergei+local@maykinmedia.nl>\n"
"Language-Team: Dutch <support@maykinmedia.nl>\n"
Expand Down Expand Up @@ -4464,14 +4464,14 @@ msgstr ""
msgid "Formio integration"
msgstr "Form.io-integratie"

#: openforms/formio/components/custom.py:179
#: openforms/formio/components/custom.py:371
#: openforms/formio/components/custom.py:245
#: openforms/formio/components/custom.py:437
#: openforms/formio/components/vanilla.py:111
#: openforms/formio/components/vanilla.py:272
msgid "This value does not match the required pattern."
msgstr "De waarde komt niet overeen met het verwachte patroon."

#: openforms/formio/components/custom.py:234
#: openforms/formio/components/custom.py:300
msgid "Selecting family members is currently not available."
msgstr "Het selecteren van gezinsleden is momenteel niet beschikbaar."

Expand Down Expand Up @@ -8303,8 +8303,8 @@ msgid ""
"The co-sign component requires the '{field_label}' ({config_verbose_name}) "
"to be configured."
msgstr ""
"Het mede-ondertekencomponent vereist de configuratie van "
"'{field_label}' ({config_verbose_name})."
"Het mede-ondertekencomponent vereist de configuratie van '{field_label}' "
"({config_verbose_name})."

#: openforms/products/api/viewsets.py:15
msgid "Retrieve details of a single product"
Expand Down
70 changes: 68 additions & 2 deletions src/openforms/formio/components/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@
from typing import Protocol

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

from rest_framework import serializers
from rest_framework import ISO_8601, serializers
from rest_framework.request import Request

from openforms.authentication.constants import AuthAttribute
from openforms.config.models import GlobalConfiguration
from openforms.submissions.models import Submission
from openforms.typing import DataMapping
from openforms.utils.date import datetime_in_amsterdam, format_date_value
from openforms.utils.date import TIMEZONE_AMS, datetime_in_amsterdam, format_date_value
from openforms.utils.validators import BSNValidator, IBANValidator
from openforms.validations.service import PluginValidator

Expand Down Expand Up @@ -100,6 +101,39 @@ def build_serializer_field(
return serializers.ListField(child=base) if multiple else base


class FormioDateTimeField(serializers.DateTimeField):
def validate_empty_values(self, data):
is_empty, data = super().validate_empty_values(data)
# base field only treats `None` as empty, but formio uses empty strings
if data == "":
if self.required:
self.fail("required")
return (True, "")
return is_empty, data

def to_internal_value(self, value):
# we *only* accept datetimes in ISO-8601 format. Python will happily parse a
# YYYY-MM-DD string as a datetime (with hours/minutes set to 0). For a component
# specifically aimed at datetimes, this is not a valid input.
if value and isinstance(value, str) and "T" not in value:
self.fail("invalid", format="YYYY-MM-DDTHH:mm:ss+XX:YY")
return super().to_internal_value(value)


def _normalize_validation_datetime(value: str) -> datetime:
"""
Takes a string expected to contain an ISO-8601 datetime and normalizes it.

Seconds and time zone information may be missing. If it is, assume Europe/Amsterdam.

:return: Time-zone aware datetime.
"""
parsed = datetime.fromisoformat(value)
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed, timezone=TIMEZONE_AMS)
return parsed


@register("datetime")
class Datetime(BasePlugin):
formatter = DateTimeFormatter
Expand All @@ -115,6 +149,38 @@ def mutate_config_dynamically(
"""
mutate_min_max_validation(component, data)

def build_serializer_field(
self, component: DateComponent
) -> FormioDateTimeField | serializers.ListField:
"""
Accept datetime 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.maxDate
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"):
min_value = _normalize_validation_datetime(min_date)
validators.append(MinValueValidator(min_value))

if max_date := date_picker.get("maxDate"):
max_value = _normalize_validation_datetime(max_date)
validators.append(MaxValueValidator(max_value))

base = FormioDateTimeField(
input_formats=[ISO_8601],
required=required,
allow_null=not required,
validators=validators,
)
return serializers.ListField(child=base) if multiple else base


@register("map")
class Map(BasePlugin[Component]):
Expand Down
150 changes: 150 additions & 0 deletions src/openforms/formio/tests/validation/test_datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
from django.test import SimpleTestCase
from django.utils import timezone

from openforms.submissions.models import Submission
from openforms.typing import JSONValue

from ...datastructures import FormioConfigurationWrapper
from ...dynamic_config import rewrite_formio_components
from ...typing import DatetimeComponent
from .helpers import extract_error, validate_formio_data


class DatetimeFieldValidationTests(SimpleTestCase):

def test_datetimefield_required_validation(self):
component: DatetimeComponent = {
"type": "datetime",
"key": "foo",
"label": "Foo",
"validate": {"required": True},
}

invalid_values = [
({}, "required"),
({"foo": None}, "null"),
({"foo": ""}, "required"),
]

for data, error_code in invalid_values:
with self.subTest(data=data):
is_valid, errors = validate_formio_data(component, data)

self.assertFalse(is_valid)
self.assertIn(component["key"], errors)
error = extract_error(errors, component["key"])
self.assertEqual(error.code, error_code)

def test_min_max_datetime(self):
# variant with explicit fixed values
component: DatetimeComponent = {
"type": "datetime",
"key": "foo",
"label": "Foo",
"validate": {"required": False},
"openForms": {
"minDate": {"mode": "fixedValue"},
"maxDate": {"mode": "fixedValue"},
},
"datePicker": {
"minDate": "2024-03-10T12:00",
"maxDate": "2025-03-10T22:30",
},
}

invalid_values = [
({"foo": "2023-01-01T12:00:00+00:00"}, "min_value"),
({"foo": "2025-05-17T12:00:00+02:00"}, "max_value"),
({"foo": "2024-05-17"}, "invalid"),
({"foo": "blah"}, "invalid"),
]

for data, error_code in invalid_values:
with self.subTest(data=data):
is_valid, errors = validate_formio_data(component, data)

self.assertFalse(is_valid)
self.assertIn(component["key"], errors)
error = extract_error(errors, component["key"])
self.assertEqual(error.code, error_code)

valid_values = [
"2024-05-06T13:49:00+02:00",
"2024-03-10T12:00:00+01:00", # exactly on the minimum value, without DST
"2025-03-10T22:30:00+01:00", # exactly on the maximum value, without DST
]
for value in valid_values:
with self.subTest("valid value", value=value):
is_valid, _ = validate_formio_data(component, {"foo": value})

self.assertTrue(is_valid)

def test_dynamic_configuration(self):
component: DatetimeComponent = {
"type": "datetime",
"key": "foo",
"label": "Foo",
"validate": {"required": False},
"openForms": {
"minDate": {
"mode": "future",
},
},
}
submission = Submission() # this test is not supposed to hit the DB
config_wraper = FormioConfigurationWrapper(
configuration={"components": [component]}
)
now = timezone.now()
config_wraper = rewrite_formio_components(
config_wraper,
submission=submission,
data={"now": now},
)

updated_component = config_wraper["foo"]
# check that rewrite_formio_components behaved as expected
assert "datePicker" in updated_component
assert "minDate" in updated_component["datePicker"]

with self.subTest("valid value"):
is_valid, _ = validate_formio_data(component, {"foo": now.isoformat()})

self.assertTrue(is_valid)

with self.subTest("invalid value"):
is_valid, _ = validate_formio_data(
component, {"foo": "2020-01-01T12:00:00+01:00"}
)

self.assertFalse(is_valid)

def test_empty_default_value(self):
component: DatetimeComponent = {
"type": "datetime",
"key": "datetime",
"label": "Optional datetime",
"validate": {"required": False},
}

is_valid, _ = validate_formio_data(component, {"datetime": ""})

self.assertTrue(is_valid)

def test_multiple(self):
component: DatetimeComponent = {
"type": "datetime",
"key": "datetimes",
"label": "Multiple datetimes",
"multiple": True,
}
data: JSONValue = {"datetimes": ["2024-01-01T00:00:00+00:00", "notdatetime"]}

is_valid, errors = validate_formio_data(component, data)

self.assertFalse(is_valid)
error = errors["datetimes"][1][0]
self.assertEqual(error.code, "invalid")

with self.subTest("valid item"):
self.assertNotIn(0, errors["datetimes"])
Loading
Loading