From 44c0dec68b4153549c2c750ffee8f4766748983a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 9 Jun 2021 13:10:35 -0400 Subject: [PATCH] Extend CustomValidator to support required, prohibited fields --- docs/additional-features/custom-validation.md | 7 +- netbox/extras/tests/test_customvalidator.py | 86 ++++++++++++++----- netbox/extras/validators.py | 35 ++++++++ 3 files changed, 107 insertions(+), 21 deletions(-) diff --git a/docs/additional-features/custom-validation.md b/docs/additional-features/custom-validation.md index 23d45eee3..720e8e487 100644 --- a/docs/additional-features/custom-validation.md +++ b/docs/additional-features/custom-validation.md @@ -28,8 +28,13 @@ The `CustomValidator` class supports several validation types: * `min_length`: Minimum string length * `max_length`: Maximum string length * `regex`: Application of a [regular expression](https://en.wikipedia.org/wiki/Regular_expression) +* `required`: A value must be specified +* `prohibited`: A value must _not_ be specified -The `min` and `max` types should be defined for numeric values, whereas `min_length`, `max_length`, and `regex` are suitable for character strings (text values). +The `min` and `max` types should be defined for numeric values, whereas `min_length`, `max_length`, and `regex` are suitable for character strings (text values). The `required` and `prohibited` validators may be used for any field, and should be passed a value of `True`. + +!!! warning + Bear in mind that these validators merely supplement NetBox's own validation: They will not override it. For example, if a certain model field is required by NetBox, setting a validator for it with `{'prohibited': True}` will not work. ### Custom Validation Logic diff --git a/netbox/extras/tests/test_customvalidator.py b/netbox/extras/tests/test_customvalidator.py index f1b2477a0..373303fb1 100644 --- a/netbox/extras/tests/test_customvalidator.py +++ b/netbox/extras/tests/test_customvalidator.py @@ -13,15 +13,51 @@ class MyValidator(CustomValidator): self.fail("Name must be foo!") -stock_validator = CustomValidator({ - 'name': { - 'min_length': 5, - 'max_length': 10, - 'regex': r'\d{3}$', # Ends with three digits - }, +min_validator = CustomValidator({ 'asn': { - 'min': 65000, - 'max': 65100, + 'min': 65000 + } +}) + + +max_validator = CustomValidator({ + 'asn': { + 'max': 65100 + } +}) + + +min_length_validator = CustomValidator({ + 'name': { + 'min_length': 5 + } +}) + + +max_length_validator = CustomValidator({ + 'name': { + 'max_length': 10 + } +}) + + +regex_validator = CustomValidator({ + 'name': { + 'regex': r'\d{3}$' # Ends with three digits + } +}) + + +required_validator = CustomValidator({ + 'description': { + 'required': True + } +}) + + +prohibited_validator = CustomValidator({ + 'description': { + 'prohibited': True } }) @@ -30,46 +66,56 @@ custom_validator = MyValidator() class CustomValidatorTest(TestCase): - @override_settings(CUSTOM_VALIDATORS={'dcim.site': [stock_validator]}) + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_validator]}) def test_configuration(self): self.assertIn('dcim.site', settings.CUSTOM_VALIDATORS) validator = settings.CUSTOM_VALIDATORS['dcim.site'][0] self.assertIsInstance(validator, CustomValidator) - @override_settings(CUSTOM_VALIDATORS={'dcim.site': [stock_validator]}) + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_validator]}) def test_min(self): with self.assertRaises(ValidationError): Site(name='abcdef123', slug='abcdefghijk', asn=1).clean() - @override_settings(CUSTOM_VALIDATORS={'dcim.site': [stock_validator]}) + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [max_validator]}) def test_max(self): with self.assertRaises(ValidationError): Site(name='abcdef123', slug='abcdefghijk', asn=65535).clean() - @override_settings(CUSTOM_VALIDATORS={'dcim.site': [stock_validator]}) + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]}) def test_min_length(self): with self.assertRaises(ValidationError): Site(name='abc', slug='abc', asn=65000).clean() - @override_settings(CUSTOM_VALIDATORS={'dcim.site': [stock_validator]}) + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [max_length_validator]}) def test_max_length(self): with self.assertRaises(ValidationError): - Site(name='abcdefghijk', slug='abcdefghijk', asn=65000).clean() + Site(name='abcdefghijk', slug='abcdefghijk').clean() - @override_settings(CUSTOM_VALIDATORS={'dcim.site': [stock_validator]}) + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [regex_validator]}) def test_regex(self): with self.assertRaises(ValidationError): - Site(name='abcdefgh', slug='abcdefgh', asn=65000).clean() + Site(name='abcdefgh', slug='abcdefgh').clean() - @override_settings(CUSTOM_VALIDATORS={'dcim.site': [stock_validator]}) + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [required_validator]}) + def test_required(self): + with self.assertRaises(ValidationError): + Site(name='abcdefgh', slug='abcdefgh', description='').clean() + + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [prohibited_validator]}) + def test_prohibited(self): + with self.assertRaises(ValidationError): + Site(name='abcdefgh', slug='abcdefgh', description='ABC').clean() + + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]}) def test_valid(self): - Site(name='abcdef123', slug='abcdef123', asn=65000).clean() + Site(name='abcdef123', slug='abcdef123').clean() @override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]}) def test_custom_invalid(self): with self.assertRaises(ValidationError): - Site(name='abc', slug='abc', asn=65000).clean() + Site(name='abc', slug='abc').clean() @override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]}) def test_custom_valid(self): - Site(name='foo', slug='foo', asn=65000).clean() + Site(name='foo', slug='foo').clean() diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py index a880ed127..686c9b032 100644 --- a/netbox/extras/validators.py +++ b/netbox/extras/validators.py @@ -1,6 +1,39 @@ from django.core.exceptions import ValidationError from django.core import validators +# NOTE: As this module may be imported by configuration.py, we cannot import +# anything from NetBox itself. + + +class IsEmptyValidator: + """ + Employed by CustomValidator to enforce required fields. + """ + message = "This field must be empty." + code = 'is_empty' + + def __init__(self, enforce=True): + self._enforce = enforce + + def __call__(self, value): + if self._enforce and value not in validators.EMPTY_VALUES: + raise ValidationError(self.message, code=self.code) + + +class IsNotEmptyValidator: + """ + Employed by CustomValidator to enforce prohibited fields. + """ + message = "This field must not be empty." + code = 'not_empty' + + def __init__(self, enforce=True): + self._enforce = enforce + + def __call__(self, value): + if self._enforce and value in validators.EMPTY_VALUES: + raise ValidationError(self.message, code=self.code) + class CustomValidator: """ @@ -22,6 +55,8 @@ class CustomValidator: 'min_length': validators.MinLengthValidator, 'max_length': validators.MaxLengthValidator, 'regex': validators.RegexValidator, + 'required': IsNotEmptyValidator, + 'prohibited': IsEmptyValidator, } def __init__(self, validation_rules=None):