Skip to content

Commit 9839885

Browse files
Merge pull request #6576 from netbox-community/5963-custom-validation
Closes #5963: Custom model validation
2 parents 1847218 + 44c0dec commit 9839885

File tree

11 files changed

+387
-5
lines changed

11 files changed

+387
-5
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Custom Validation
2+
3+
NetBox validates every object prior to it being written to the database to ensure data integrity. This validation includes things like checking for proper formatting and that references to related objects are valid. However, you may wish to supplement this validation with some rules of your own. For example, perhaps you require that every site's name conforms to a specific pattern. This can be done using NetBox's `CustomValidator` class.
4+
5+
## CustomValidator
6+
7+
### Validation Rules
8+
9+
A custom validator can be instantiated by passing a mapping of attributes to a set of rules to which that attribute must conform. For example:
10+
11+
```python
12+
from extras.validators import CustomValidator
13+
14+
CustomValidator({
15+
'name': {
16+
'min_length': 5,
17+
'max_length': 30,
18+
}
19+
})
20+
```
21+
22+
This defines a custom validator which checks that the length of the `name` attribute for an object is at least five characters long, and no longer than 30 characters. This validation is executed _after_ NetBox has performed its own internal validation.
23+
24+
The `CustomValidator` class supports several validation types:
25+
26+
* `min`: Minimum value
27+
* `max`: Maximum value
28+
* `min_length`: Minimum string length
29+
* `max_length`: Maximum string length
30+
* `regex`: Application of a [regular expression](https://en.wikipedia.org/wiki/Regular_expression)
31+
* `required`: A value must be specified
32+
* `prohibited`: A value must _not_ be specified
33+
34+
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`.
35+
36+
!!! warning
37+
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.
38+
39+
### Custom Validation Logic
40+
41+
There may be instances where the provided validation types are insufficient. The `CustomValidator` class can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected.
42+
43+
```python
44+
from extras.validators import CustomValidator
45+
46+
class MyValidator(CustomValidator):
47+
def validate(self, instance):
48+
if instance.status == 'active' and not instance.description:
49+
self.fail("Active sites must have a description set!", field='status')
50+
```
51+
52+
The `fail()` method may optionally specify a field with which to associate the supplied error message. If specified, the error message will appear to the user as associated with this field. If omitted, the error message will not be associated with any field.
53+
54+
## Assigning Custom Validators
55+
56+
Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter, as such:
57+
58+
```python
59+
CUSTOM_VALIDATORS = {
60+
'dcim.site': (
61+
Validator1,
62+
Validator2,
63+
Validator3
64+
)
65+
}
66+
```
67+
68+
!!! note
69+
Even if defining only a single validator, it must be passed as an iterable.
70+
71+
When it is not necessary to define a custom `validate()` method, you may opt to pass a `CustomValidator` instance directly:
72+
73+
```python
74+
from extras.validators import CustomValidator
75+
76+
CUSTOM_VALIDATORS = {
77+
'dcim.site': (
78+
CustomValidator({
79+
'name': {
80+
'min_length': 5,
81+
'max_length': 30,
82+
}
83+
}),
84+
)
85+
}
86+
```

docs/configuration/optional-settings.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ CORS_ORIGIN_WHITELIST = [
9696

9797
---
9898

99+
## CUSTOM_VALIDATORS
100+
101+
This is a mapping of models to [custom validators](../additional-features/custom-validation.md) that have been defined locally to enforce custom validation logic.
102+
103+
---
104+
99105
## DEBUG
100106

101107
Default: False
@@ -144,7 +150,7 @@ In order to send email, NetBox needs an email server configured. The following i
144150
!!! note
145151
The `USE_SSL` and `USE_TLS` parameters are mutually exclusive.
146152

147-
Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) fuction accessible within the NetBox shell:
153+
Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) function accessible within the NetBox shell:
148154

149155
```no-highlight
150156
# python ./manage.py nbshell

docs/development/signals.md

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Signals
2+
3+
In addition to [Django's built-in signals](https://docs.djangoproject.com/en/stable/topics/signals/), NetBox defines some of its own, listed below.
4+
5+
## post_clean
6+
7+
This signal is sent by models which inherit from `CustomValidationMixin` at the end of their `clean()` method.
8+
9+
### Receivers
10+
11+
* `extras.signals.run_custom_validators()`

mkdocs.yml

+2
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ nav:
6464
- Change Logging: 'additional-features/change-logging.md'
6565
- Context Data: 'models/extras/configcontext.md'
6666
- Custom Fields: 'additional-features/custom-fields.md'
67+
- Custom Validation: 'additional-features/custom-validation.md'
6768
- Custom Links: 'additional-features/custom-links.md'
6869
- Custom Scripts: 'additional-features/custom-scripts.md'
6970
- Export Templates: 'additional-features/export-templates.md'
@@ -90,6 +91,7 @@ nav:
9091
- Style Guide: 'development/style-guide.md'
9192
- Models: 'development/models.md'
9293
- Extending Models: 'development/extending-models.md'
94+
- Signals: 'development/signals.md'
9395
- Application Registry: 'development/application-registry.md'
9496
- User Preferences: 'development/user-preferences.md'
9597
- Release Checklist: 'development/release-checklist.md'

netbox/extras/signals.py

+14
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from django.contrib.contenttypes.models import ContentType
77
from django.db import DEFAULT_DB_ALIAS
88
from django.db.models.signals import m2m_changed, post_save, pre_delete
9+
from django.dispatch import receiver
910
from django.utils import timezone
1011
from django_prometheus.models import model_deletes, model_inserts, model_updates
1112
from prometheus_client import Counter
1213

14+
from netbox.signals import post_clean
1315
from .choices import ObjectChangeActionChoices
1416
from .models import CustomField, ObjectChange
1517
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
@@ -136,6 +138,18 @@ def handle_cf_deleted(instance, **kwargs):
136138
pre_delete.connect(handle_cf_deleted, sender=CustomField)
137139

138140

141+
#
142+
# Custom validation
143+
#
144+
145+
@receiver(post_clean)
146+
def run_custom_validators(sender, instance, **kwargs):
147+
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
148+
validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
149+
for validator in validators:
150+
validator(instance)
151+
152+
139153
#
140154
# Caching
141155
#
+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from django.conf import settings
2+
from django.core.exceptions import ValidationError
3+
from django.test import TestCase, override_settings
4+
5+
from dcim.models import Site
6+
from extras.validators import CustomValidator
7+
8+
9+
class MyValidator(CustomValidator):
10+
11+
def validate(self, instance):
12+
if instance.name != 'foo':
13+
self.fail("Name must be foo!")
14+
15+
16+
min_validator = CustomValidator({
17+
'asn': {
18+
'min': 65000
19+
}
20+
})
21+
22+
23+
max_validator = CustomValidator({
24+
'asn': {
25+
'max': 65100
26+
}
27+
})
28+
29+
30+
min_length_validator = CustomValidator({
31+
'name': {
32+
'min_length': 5
33+
}
34+
})
35+
36+
37+
max_length_validator = CustomValidator({
38+
'name': {
39+
'max_length': 10
40+
}
41+
})
42+
43+
44+
regex_validator = CustomValidator({
45+
'name': {
46+
'regex': r'\d{3}$' # Ends with three digits
47+
}
48+
})
49+
50+
51+
required_validator = CustomValidator({
52+
'description': {
53+
'required': True
54+
}
55+
})
56+
57+
58+
prohibited_validator = CustomValidator({
59+
'description': {
60+
'prohibited': True
61+
}
62+
})
63+
64+
custom_validator = MyValidator()
65+
66+
67+
class CustomValidatorTest(TestCase):
68+
69+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_validator]})
70+
def test_configuration(self):
71+
self.assertIn('dcim.site', settings.CUSTOM_VALIDATORS)
72+
validator = settings.CUSTOM_VALIDATORS['dcim.site'][0]
73+
self.assertIsInstance(validator, CustomValidator)
74+
75+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_validator]})
76+
def test_min(self):
77+
with self.assertRaises(ValidationError):
78+
Site(name='abcdef123', slug='abcdefghijk', asn=1).clean()
79+
80+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [max_validator]})
81+
def test_max(self):
82+
with self.assertRaises(ValidationError):
83+
Site(name='abcdef123', slug='abcdefghijk', asn=65535).clean()
84+
85+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]})
86+
def test_min_length(self):
87+
with self.assertRaises(ValidationError):
88+
Site(name='abc', slug='abc', asn=65000).clean()
89+
90+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [max_length_validator]})
91+
def test_max_length(self):
92+
with self.assertRaises(ValidationError):
93+
Site(name='abcdefghijk', slug='abcdefghijk').clean()
94+
95+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [regex_validator]})
96+
def test_regex(self):
97+
with self.assertRaises(ValidationError):
98+
Site(name='abcdefgh', slug='abcdefgh').clean()
99+
100+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [required_validator]})
101+
def test_required(self):
102+
with self.assertRaises(ValidationError):
103+
Site(name='abcdefgh', slug='abcdefgh', description='').clean()
104+
105+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [prohibited_validator]})
106+
def test_prohibited(self):
107+
with self.assertRaises(ValidationError):
108+
Site(name='abcdefgh', slug='abcdefgh', description='ABC').clean()
109+
110+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]})
111+
def test_valid(self):
112+
Site(name='abcdef123', slug='abcdef123').clean()
113+
114+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]})
115+
def test_custom_invalid(self):
116+
with self.assertRaises(ValidationError):
117+
Site(name='abc', slug='abc').clean()
118+
119+
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]})
120+
def test_custom_valid(self):
121+
Site(name='foo', slug='foo').clean()

netbox/extras/validators.py

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from django.core.exceptions import ValidationError
2+
from django.core import validators
3+
4+
# NOTE: As this module may be imported by configuration.py, we cannot import
5+
# anything from NetBox itself.
6+
7+
8+
class IsEmptyValidator:
9+
"""
10+
Employed by CustomValidator to enforce required fields.
11+
"""
12+
message = "This field must be empty."
13+
code = 'is_empty'
14+
15+
def __init__(self, enforce=True):
16+
self._enforce = enforce
17+
18+
def __call__(self, value):
19+
if self._enforce and value not in validators.EMPTY_VALUES:
20+
raise ValidationError(self.message, code=self.code)
21+
22+
23+
class IsNotEmptyValidator:
24+
"""
25+
Employed by CustomValidator to enforce prohibited fields.
26+
"""
27+
message = "This field must not be empty."
28+
code = 'not_empty'
29+
30+
def __init__(self, enforce=True):
31+
self._enforce = enforce
32+
33+
def __call__(self, value):
34+
if self._enforce and value in validators.EMPTY_VALUES:
35+
raise ValidationError(self.message, code=self.code)
36+
37+
38+
class CustomValidator:
39+
"""
40+
This class enables the application of user-defined validation rules to NetBox models. It can be instantiated by
41+
passing a dictionary of validation rules in the form {attribute: rules}, where 'rules' is a dictionary mapping
42+
descriptors (e.g. min_length or regex) to values.
43+
44+
A CustomValidator instance is applied by calling it with the instance being validated:
45+
46+
validator = CustomValidator({'name': {'min_length: 10}})
47+
site = Site(name='abcdef')
48+
validator(site) # Raises ValidationError
49+
50+
:param validation_rules: A dictionary mapping object attributes to validation rules
51+
"""
52+
VALIDATORS = {
53+
'min': validators.MinValueValidator,
54+
'max': validators.MaxValueValidator,
55+
'min_length': validators.MinLengthValidator,
56+
'max_length': validators.MaxLengthValidator,
57+
'regex': validators.RegexValidator,
58+
'required': IsNotEmptyValidator,
59+
'prohibited': IsEmptyValidator,
60+
}
61+
62+
def __init__(self, validation_rules=None):
63+
self.validation_rules = validation_rules or {}
64+
assert type(self.validation_rules) is dict, "Validation rules must be passed as a dictionary"
65+
66+
def __call__(self, instance):
67+
# Validate instance attributes per validation rules
68+
for attr_name, rules in self.validation_rules.items():
69+
assert hasattr(instance, attr_name), f"Invalid attribute '{attr_name}' for {instance.__class__.__name__}"
70+
attr = getattr(instance, attr_name)
71+
for descriptor, value in rules.items():
72+
validator = self.get_validator(descriptor, value)
73+
try:
74+
validator(attr)
75+
except ValidationError as exc:
76+
# Re-package the raised ValidationError to associate it with the specific attr
77+
raise ValidationError({attr_name: exc})
78+
79+
# Execute custom validation logic (if any)
80+
self.validate(instance)
81+
82+
def get_validator(self, descriptor, value):
83+
"""
84+
Instantiate and return the appropriate validator based on the descriptor given. For
85+
example, 'min' returns MinValueValidator(value).
86+
"""
87+
if descriptor not in self.VALIDATORS:
88+
raise NotImplementedError(
89+
f"Unknown validation type for {self.__class__.__name__}: '{descriptor}'"
90+
)
91+
validator_cls = self.VALIDATORS.get(descriptor)
92+
return validator_cls(value)
93+
94+
def validate(self, instance):
95+
"""
96+
Custom validation method, to be overridden by the user. Validation failures should
97+
raise a ValidationError exception.
98+
"""
99+
return
100+
101+
def fail(self, message, field=None):
102+
"""
103+
Raise a ValidationError exception. Associate the provided message with a form/serializer field if specified.
104+
"""
105+
if field is not None:
106+
raise ValidationError({field: message})
107+
raise ValidationError(message)

0 commit comments

Comments
 (0)