Fix #12731: Add custom model validation from forms and serializers

This allows for validation of any m2m relation like tags.
The validator receives the cleaned data from the form/serializer.
The model instance is not consistently available in the model
forms/serializers so is omitted from both.

Note: the available fields can vary per form/serializer for a model.
You should always check if a field is defined in the cleaned data.
This commit is contained in:
Harm Geerts 2023-11-02 17:40:21 +01:00
parent b3fb393490
commit f32a909b9a
No known key found for this signature in database
GPG Key ID: 9B5DAC50E1850C10
6 changed files with 147 additions and 4 deletions

View File

@ -9,7 +9,7 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
from extras.validators import CustomValidator from extras.validators import CustomValidator
from netbox.config import get_config from netbox.config import get_config
from netbox.context import current_request, webhooks_queue from netbox.context import current_request, webhooks_queue
from netbox.signals import post_clean from netbox.signals import post_clean, post_form_clean, post_serializer_clean
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices from .choices import ObjectChangeActionChoices
from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem
@ -178,12 +178,17 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
# Custom validation # Custom validation
# #
@receiver(post_clean) @receiver([post_clean, post_form_clean, post_serializer_clean])
def run_custom_validators(sender, instance, **kwargs): def run_custom_validators(signal, sender, instance=None, data=None, **kwargs):
config = get_config() config = get_config()
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = config.CUSTOM_VALIDATORS.get(model_name, []) validators = config.CUSTOM_VALIDATORS.get(model_name, [])
if signal is post_clean:
assert instance is not None
else:
assert data is not None
for validator in validators: for validator in validators:
# Loading a validator class by dotted path # Loading a validator class by dotted path
@ -195,6 +200,11 @@ def run_custom_validators(sender, instance, **kwargs):
elif type(validator) is dict: elif type(validator) is dict:
validator = CustomValidator(validator) validator = CustomValidator(validator)
if signal is post_form_clean:
validator.validate_form_data(data)
elif signal is post_serializer_clean:
validator.validate_serializer_data(data)
else:
validator(instance) validator(instance)

View File

@ -4,6 +4,9 @@ from django.test import TestCase, override_settings
from ipam.models import ASN, RIR from ipam.models import ASN, RIR
from dcim.models import Site from dcim.models import Site
from dcim.api.serializers import SiteSerializer
from dcim.forms.model_forms import SiteForm
from extras.models import Tag
from extras.validators import CustomValidator from extras.validators import CustomValidator
@ -14,6 +17,33 @@ class MyValidator(CustomValidator):
self.fail("Name must be foo!") self.fail("Name must be foo!")
class FooTagValidation:
def validate_foo_tag(self, data):
if data['name'] != 'foo':
for tag in data['tags']:
if tag.name == 'FOO':
self.fail('FOO tag is reserved for site foo', 'tags')
class MyDataValidator(FooTagValidation, CustomValidator):
def validate_data(self, data):
self.validate_foo_tag(data)
class MyFormValidator(FooTagValidation, CustomValidator):
def validate_form_data(self, data):
self.validate_foo_tag(data)
class MySerializerValidator(FooTagValidation, CustomValidator):
def validate_serializer_data(self, data):
self.validate_foo_tag(data)
min_validator = CustomValidator({ min_validator = CustomValidator({
'asn': { 'asn': {
'min': 65000 'min': 65000
@ -64,12 +94,31 @@ prohibited_validator = CustomValidator({
custom_validator = MyValidator() custom_validator = MyValidator()
custom_data_validator = MyDataValidator()
custom_form_validator = MyFormValidator()
custom_serializer_validator = MySerializerValidator()
class CustomValidatorTest(TestCase): class CustomValidatorTest(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
RIR.objects.create(name='RIR 1', slug='rir-1') RIR.objects.create(name='RIR 1', slug='rir-1')
tag = Tag.objects.create(name='FOO', slug='foo')
cls.valid_data = {
'name': 'foo',
'slug': 'foo',
'status': 'active',
'tags': [tag.pk],
}
cls.invalid_data = {
'name': 'abc',
'slug': 'abc',
'status': 'active',
'tags': [tag.pk],
}
@override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]}) @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]})
def test_configuration(self): def test_configuration(self):
@ -125,6 +174,54 @@ class CustomValidatorTest(TestCase):
def test_custom_valid(self): def test_custom_valid(self):
Site(name='foo', slug='foo').clean() Site(name='foo', slug='foo').clean()
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_data_validator]})
def test_custom_data_invalid(self):
form = SiteForm(self.invalid_data)
self.assertFalse(form.is_valid())
self.assertIn('tags', form.errors)
serializer = SiteSerializer(data=self.invalid_data)
self.assertFalse(serializer.is_valid())
self.assertIn('tags', serializer.errors)
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_data_validator]})
def test_custom_data_valid(self):
form = SiteForm(self.valid_data)
self.assertTrue(form.is_valid(), form.errors.as_data())
serializer = SiteSerializer(data=self.valid_data)
self.assertTrue(serializer.is_valid(), serializer.errors)
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_form_validator]})
def test_custom_form_invalid(self):
form = SiteForm(self.invalid_data)
self.assertFalse(form.is_valid())
self.assertIn('tags', form.errors)
# Form validator does not affect serializer validation.
serializer = SiteSerializer(data=self.invalid_data)
self.assertTrue(serializer.is_valid(), serializer.errors)
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_form_validator]})
def test_custom_form_valid(self):
form = SiteForm(self.valid_data)
self.assertTrue(form.is_valid(), form.errors.as_data())
serializer = SiteSerializer(data=self.valid_data)
self.assertTrue(serializer.is_valid(), serializer.errors)
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_serializer_validator]})
def test_custom_serializer_invalid(self):
# Serializer validator does not affect form validation.
form = SiteForm(self.invalid_data)
self.assertTrue(form.is_valid(), form.errors.as_data())
serializer = SiteSerializer(data=self.invalid_data)
self.assertFalse(serializer.is_valid())
self.assertIn('tags', serializer.errors)
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_serializer_validator]})
def test_custom_serializer_valid(self):
form = SiteForm(self.valid_data)
self.assertTrue(form.is_valid(), form.errors.as_data())
serializer = SiteSerializer(data=self.valid_data)
self.assertTrue(serializer.is_valid(), serializer.errors)
class CustomValidatorConfigTest(TestCase): class CustomValidatorConfigTest(TestCase):

View File

@ -98,6 +98,27 @@ class CustomValidator:
""" """
return return
def validate_data(self, data):
"""
Custom validation method for model forms and model serializers, to be overridden by the user.
Validation failures should raise a ValidationError exception.
"""
return
def validate_form_data(self, data):
"""
Custom validation method for model forms, to be overridden by the user.
Validation failures should raise a ValidationError exception.
"""
return self.validate_data(data)
def validate_serializer_data(self, data):
"""
Custom validation method for model serializers, to be overridden by the user.
Validation failures should raise a ValidationError exception.
"""
return self.validate_data(data)
def fail(self, message, field=None): def fail(self, message, field=None):
""" """
Raise a ValidationError exception. Associate the provided message with a form/serializer field if specified. Raise a ValidationError exception. Associate the provided message with a form/serializer field if specified.

View File

@ -3,6 +3,8 @@ from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from netbox.signals import post_serializer_clean
__all__ = ( __all__ = (
'BaseModelSerializer', 'BaseModelSerializer',
'ValidatedModelSerializer', 'ValidatedModelSerializer',
@ -43,4 +45,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
setattr(instance, k, v) setattr(instance, k, v)
instance.full_clean() instance.full_clean()
# Send the post_serializer_clean signal
post_serializer_clean.send(sender=self.Meta.model, data=data)
return data return data

View File

@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
from extras.models import CustomField, Tag from extras.models import CustomField, Tag
from netbox.signals import post_form_clean
from utilities.forms import CSVModelForm from utilities.forms import CSVModelForm
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin
@ -55,6 +56,9 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
else: else:
self.instance.custom_field_data[key] = customfield.serialize(value) self.instance.custom_field_data[key] = customfield.serialize(value)
# Send the post_form_clean signal
post_form_clean.send(sender=self._meta.model, data=self.cleaned_data)
return super().clean() return super().clean()

View File

@ -3,3 +3,9 @@ from django.dispatch import Signal
# Signals that a model has completed its clean() method # Signals that a model has completed its clean() method
post_clean = Signal() post_clean = Signal()
# Signals that a model form has completed its clean() method
post_form_clean = Signal()
# Signals that a model serializer has completed its validate() method
post_serializer_clean = Signal()