Skip to content

Commit

Permalink
Merge pull request #3033 from open-formulieren/issue/1879-validation-…
Browse files Browse the repository at this point in the history
…plugins

[#1879] display only relevant validation plugins
  • Loading branch information
sergei-maertens authored May 4, 2023
2 parents 769a81b + 78b2a74 commit 78e7ad6
Show file tree
Hide file tree
Showing 10 changed files with 208 additions and 26 deletions.
14 changes: 14 additions & 0 deletions src/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4589,6 +4589,13 @@ paths:
operationId: validation_plugins_list
description: List all prefill plugins that have been registered.
summary: List available validation plugins
parameters:
- in: query
name: componentType
schema:
type: string
description: Only return validators applicable for the specified component
type.
tags:
- validation
security:
Expand Down Expand Up @@ -8254,7 +8261,14 @@ components:
label:
type: string
description: The human-readable name for a plugin.
forComponents:
type: array
items:
type: string
title: Components
description: The components for which the plugin is relevant.
required:
- forComponents
- id
- label
ValidationResult:
Expand Down
1 change: 1 addition & 0 deletions src/openforms/api/views/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ def get_serializer(self, *args, **kwargs):
def get(self, request, *args, **kwargs):
objects = self.get_objects()
serializer = self.get_serializer(instance=objects)

return Response(serializer.data)
12 changes: 8 additions & 4 deletions src/openforms/contrib/kvk/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
@deconstructible
class NumericBaseValidator:
value_size: int = NotImplemented
value_label: str = NotImplemented
value_label: int = NotImplemented
error_messages = {
"too_short": _("%(type)s should have %(size)i characters."),
}
Expand Down Expand Up @@ -77,7 +77,7 @@ def __call__(self, value):
return True


@register("kvk-kvkNumber", verbose_name=_("KvK number"))
@register("kvk-kvkNumber", verbose_name=_("KvK number"), for_components=("textfield",))
@deconstructible
class KVKNumberRemoteValidator(KVKRemoteBaseValidator):
query_param = "kvkNummer"
Expand All @@ -88,7 +88,7 @@ def __call__(self, value):
super().__call__(value)


@register("kvk-rsin", verbose_name=_("KvK RSIN"))
@register("kvk-rsin", verbose_name=_("KvK RSIN"), for_components=("textfield",))
@deconstructible
class KVKRSINRemoteValidator(KVKRemoteBaseValidator):
query_param = "rsin"
Expand All @@ -99,7 +99,11 @@ def __call__(self, value):
super().__call__(value)


@register("kvk-branchNumber", verbose_name=_("KvK branch number"))
@register(
"kvk-branchNumber",
verbose_name=_("KvK branch number"),
for_components=("textfield",),
)
@deconstructible
class KVKBranchNumberRemoteValidator(KVKRemoteBaseValidator):
query_param = "vestigingsnummer"
Expand Down
2 changes: 1 addition & 1 deletion src/openforms/js/components/form/edit/tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ const VALIDATION = getValidationEditForm({
// if the url starts with '/', then formio will prefix it with the formio
// base URL, which is of course wrong. We there explicitly use the detected
// host.
url: getFullyQualifiedUrl('/api/v2/validation/plugins'),
url: getFullyQualifiedUrl('/api/v2/validation/plugins?component={{ data.type }}'),
},
valueProperty: 'id',
template: '<span>{{ item.label }}</span>',
Expand Down
35 changes: 35 additions & 0 deletions src/openforms/validations/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,44 @@
from typing import cast

from django.utils.translation import ugettext_lazy as _

from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from rest_framework import serializers

from openforms.api.utils import underscore_to_camel


class ValidationInputSerializer(serializers.Serializer):
value = serializers.CharField(
label=_("value"), help_text=_("Value to be validated")
)


class ValidatorsFilterSerializer(serializers.Serializer):
component_type = serializers.CharField(
required=False,
label=_("Form.io component type"),
help_text=_(
"Only return validators applicable for the specified component type."
),
)

@classmethod
def as_openapi_params(cls) -> list[OpenApiParameter]:
# FIXME: this should be solved as an extension instead, but at least it's now
# kept together
instance = cls()
ct_field = cast(serializers.CharField, instance.fields["component_type"])
return [
OpenApiParameter(
underscore_to_camel(cast(str, ct_field.field_name)),
OpenApiTypes.STR,
description=cast(str, ct_field.help_text),
)
]


class ValidationResultSerializer(serializers.Serializer):
is_valid = serializers.BooleanField(
label=_("Is valid"), help_text=_("Boolean indicating value passed validation.")
Expand All @@ -33,3 +63,8 @@ class ValidationPluginSerializer(serializers.Serializer):
label=_("Label"),
help_text=_("The human-readable name for a plugin."),
)
for_components = serializers.ListField(
label=_("Components"),
help_text=_("The components for which the plugin is relevant."),
child=serializers.CharField(),
)
30 changes: 26 additions & 4 deletions src/openforms/validations/api/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Iterable

from django.utils.translation import ugettext_lazy as _

from drf_spectacular.types import OpenApiTypes
Expand All @@ -12,12 +14,17 @@
ValidationInputSerializer,
ValidationPluginSerializer,
ValidationResultSerializer,
ValidatorsFilterSerializer,
)
from openforms.validations.registry import register

from ..registry import RegisteredValidator, register


@extend_schema_view(
get=extend_schema(summary=_("List available validation plugins")),
get=extend_schema(
summary=_("List available validation plugins"),
parameters=[*ValidatorsFilterSerializer.as_openapi_params()],
),
)
class ValidatorsListView(ListMixin, APIView):
"""
Expand All @@ -28,8 +35,23 @@ class ValidatorsListView(ListMixin, APIView):
permission_classes = (permissions.IsAdminUser,)
serializer_class = ValidationPluginSerializer

def get_objects(self):
return list(register.iter_enabled_plugins())
def get_objects(self) -> list[RegisteredValidator]:
filter_serializer = ValidatorsFilterSerializer(data=self.request.query_params)
if not filter_serializer.is_valid(raise_exception=False):
return []

plugins: Iterable[RegisteredValidator] = register.iter_enabled_plugins()
for_component = filter_serializer.validated_data.get("component_type") or ""

def _iter_plugins():
for plugin in plugins:
# filter value provided but plugin does not apply for this component
# -> do not return it in the results
if for_component and for_component not in plugin.for_components:
continue
yield plugin

return list(_iter_plugins())


class ValidationView(APIView):
Expand Down
5 changes: 5 additions & 0 deletions src/openforms/validations/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class RegisteredValidator:
identifier: str
verbose_name: str
callable: ValidatorType
for_components: tuple[str]
is_demo_plugin: bool = False
# TODO always enabled for now, see: https://github.com/open-formulieren/open-forms/issues/1149
is_enabled: bool = True
Expand Down Expand Up @@ -55,6 +56,8 @@ class Registry(BaseRegistry):
The plugins can be any Django or DRF style validator;
eg: a function or callable class (or instance thereof) that raises either a Django or DRF ValidationError
The validation plugin must be relevant to the component(s);
eg: the KvKNumberValidator is relevant for textfields but not phoneNumber fields
"""

module = "validations"
Expand All @@ -64,6 +67,7 @@ def __call__(
identifier: str,
verbose_name: str,
is_demo_plugin: bool = False,
for_components: tuple[str] = tuple(),
*args,
**kwargs,
) -> Callable:
Expand All @@ -84,6 +88,7 @@ def decorator(validator: Union[Type, ValidatorType]):
identifier=identifier,
verbose_name=verbose_name,
callable=call,
for_components=for_components,
is_demo_plugin=is_demo_plugin,
)
return validator
Expand Down
120 changes: 105 additions & 15 deletions src/openforms/validations/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,20 @@ def setUp(self):
self.client.force_login(self.user)

register = Registry()
register("django", verbose_name="Django Test Validator")(DjangoValidator)
register("drf", verbose_name="DRF Test Validator")(DRFValidator)
register("func", verbose_name="Django function Validator")(function_validator)
register("demo", verbose_name="Demo function", is_demo_plugin=True)(
register(
"django",
verbose_name="Django Test Validator",
for_components=("textfield",),
)(DjangoValidator)
register(
"drf", verbose_name="DRF Test Validator", for_components=("phoneNumber",)
)(DRFValidator)
register("func", verbose_name="Django function Validator", for_components=())(
function_validator
)
register(
"demo", verbose_name="Demo function", for_components=(), is_demo_plugin=True
)(function_validator)

patcher = patch("openforms.validations.api.views.register", new=register)
patcher.start()
Expand All @@ -44,18 +52,100 @@ def test_auth_required(self):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_validations_list(self):
url = reverse("api:validators-list")
response = self.client.get(url)
with self.subTest("No query params"):
url = reverse("api:validators-list")
response = self.client.get(url)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data,
[
{"id": "django", "label": "Django Test Validator"},
{"id": "drf", "label": "DRF Test Validator"},
{"id": "func", "label": "Django function Validator"},
],
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data,
[
{
"id": "django",
"label": "Django Test Validator",
"for_components": ["textfield"],
},
{
"id": "drf",
"label": "DRF Test Validator",
"for_components": ["phoneNumber"],
},
{
"id": "func",
"label": "Django function Validator",
"for_components": [],
},
],
)

with self.subTest("Validators for textfield component"):
query_params = {"component_type": "textfield"}

response = self.client.get(reverse("api:validators-list"), query_params)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data,
[
{
"id": "django",
"label": "Django Test Validator",
"for_components": ["textfield"],
},
],
)

with self.subTest("Validators for phoneNumber component"):
query_params = {"component_type": "phoneNumber"}

response = self.client.get(reverse("api:validators-list"), query_params)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data,
[
{
"id": "drf",
"label": "DRF Test Validator",
"for_components": ["phoneNumber"],
},
],
)

with self.subTest("Optional query param"):
query_params = {"component_type": ""}

response = self.client.get(reverse("api:validators-list"), query_params)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data,
[
{
"id": "django",
"label": "Django Test Validator",
"for_components": ["textfield"],
},
{
"id": "drf",
"label": "DRF Test Validator",
"for_components": ["phoneNumber"],
},
{
"id": "func",
"label": "Django function Validator",
"for_components": [],
},
],
)

with self.subTest("Invalid query params"):
query_params = {"component_type": 123}

response = self.client.get(reverse("api:validators-list"), query_params)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, [])

def test_input_serializer(self):
self.assertTrue(ValidationInputSerializer(data={"value": "foo"}).is_valid())
Expand Down
3 changes: 3 additions & 0 deletions src/openforms/validations/tests/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

class DjangoValidator:
is_enabled = True
components = ("textfield",)

def __call__(self, value):
if value != "VALID":
Expand All @@ -17,6 +18,7 @@ def __call__(self, value):

class DRFValidator:
is_enabled = True
components = ("phoneNumber",)

def __call__(self, value):
if value != "VALID":
Expand All @@ -25,6 +27,7 @@ def __call__(self, value):

class DisabledValidator:
is_enabled = False
components = ("textfield",)

def __call__(self, value):
if value != "VALID":
Expand Down
Loading

0 comments on commit 78e7ad6

Please sign in to comment.