mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-23 07:56:44 -06:00
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:
parent
b3fb393490
commit
f32a909b9a
@ -9,7 +9,7 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
from extras.validators import CustomValidator
|
||||
from netbox.config import get_config
|
||||
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 .choices import ObjectChangeActionChoices
|
||||
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
|
||||
#
|
||||
|
||||
@receiver(post_clean)
|
||||
def run_custom_validators(sender, instance, **kwargs):
|
||||
@receiver([post_clean, post_form_clean, post_serializer_clean])
|
||||
def run_custom_validators(signal, sender, instance=None, data=None, **kwargs):
|
||||
config = get_config()
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.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:
|
||||
|
||||
# Loading a validator class by dotted path
|
||||
@ -195,6 +200,11 @@ def run_custom_validators(sender, instance, **kwargs):
|
||||
elif type(validator) is dict:
|
||||
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)
|
||||
|
||||
|
||||
|
@ -4,6 +4,9 @@ from django.test import TestCase, override_settings
|
||||
|
||||
from ipam.models import ASN, RIR
|
||||
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
|
||||
|
||||
|
||||
@ -14,6 +17,33 @@ class MyValidator(CustomValidator):
|
||||
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({
|
||||
'asn': {
|
||||
'min': 65000
|
||||
@ -64,12 +94,31 @@ prohibited_validator = CustomValidator({
|
||||
|
||||
custom_validator = MyValidator()
|
||||
|
||||
custom_data_validator = MyDataValidator()
|
||||
|
||||
custom_form_validator = MyFormValidator()
|
||||
|
||||
custom_serializer_validator = MySerializerValidator()
|
||||
|
||||
|
||||
class CustomValidatorTest(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
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]})
|
||||
def test_configuration(self):
|
||||
@ -125,6 +174,54 @@ class CustomValidatorTest(TestCase):
|
||||
def test_custom_valid(self):
|
||||
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):
|
||||
|
||||
|
@ -98,6 +98,27 @@ class CustomValidator:
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Raise a ValidationError exception. Associate the provided message with a form/serializer field if specified.
|
||||
|
@ -3,6 +3,8 @@ from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
from netbox.signals import post_serializer_clean
|
||||
|
||||
__all__ = (
|
||||
'BaseModelSerializer',
|
||||
'ValidatedModelSerializer',
|
||||
@ -43,4 +45,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
|
||||
setattr(instance, k, v)
|
||||
instance.full_clean()
|
||||
|
||||
# Send the post_serializer_clean signal
|
||||
post_serializer_clean.send(sender=self.Meta.model, data=data)
|
||||
|
||||
return data
|
||||
|
@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
|
||||
from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
|
||||
from extras.models import CustomField, Tag
|
||||
from netbox.signals import post_form_clean
|
||||
from utilities.forms import CSVModelForm
|
||||
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin
|
||||
@ -55,6 +56,9 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
|
||||
else:
|
||||
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()
|
||||
|
||||
|
||||
|
@ -3,3 +3,9 @@ from django.dispatch import Signal
|
||||
|
||||
# Signals that a model has completed its clean() method
|
||||
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()
|
||||
|
Loading…
Reference in New Issue
Block a user