Coverage for extras/validators.py: 32%
82 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import operator
3from django.core import validators
4from django.core.exceptions import ValidationError
5from django.utils.translation import gettext_lazy as _
7# NOTE: As this module may be imported by configuration.py, we cannot import
8# anything from NetBox itself.
11class IsEqualValidator(validators.BaseValidator):
12 """
13 Employed by CustomValidator to require a specific value.
14 """
15 message = _("Ensure this value is equal to %(limit_value)s.")
16 code = "is_equal"
18 def compare(self, a, b):
19 return a != b
22class IsNotEqualValidator(validators.BaseValidator):
23 """
24 Employed by CustomValidator to exclude a specific value.
25 """
26 message = _("Ensure this value does not equal %(limit_value)s.")
27 code = "is_not_equal"
29 def compare(self, a, b):
30 return a == b
33class IsEmptyValidator:
34 """
35 Employed by CustomValidator to enforce required fields.
36 """
37 message = _("This field must be empty.")
38 code = 'is_empty'
40 def __init__(self, enforce=True):
41 self._enforce = enforce
43 def __call__(self, value):
44 if self._enforce and value not in validators.EMPTY_VALUES:
45 raise ValidationError(self.message, code=self.code)
48class IsNotEmptyValidator:
49 """
50 Employed by CustomValidator to enforce prohibited fields.
51 """
52 message = _("This field must not be empty.")
53 code = 'not_empty'
55 def __init__(self, enforce=True):
56 self._enforce = enforce
58 def __call__(self, value):
59 if self._enforce and value in validators.EMPTY_VALUES:
60 raise ValidationError(self.message, code=self.code)
63class CustomValidator:
64 """
65 This class enables the application of user-defined validation rules to NetBox models. It can be instantiated by
66 passing a dictionary of validation rules in the form {attribute: rules}, where 'rules' is a dictionary mapping
67 descriptors (e.g. min_length or regex) to values.
69 A CustomValidator instance is applied by calling it with the instance being validated:
71 validator = CustomValidator({'name': {'min_length: 10}})
72 site = Site(name='abcdef')
73 validator(site) # Raises ValidationError
75 :param validation_rules: A dictionary mapping object attributes to validation rules
76 """
77 REQUEST_TOKEN = 'request'
79 VALIDATORS = {
80 'eq': IsEqualValidator,
81 'neq': IsNotEqualValidator,
82 'min': validators.MinValueValidator,
83 'max': validators.MaxValueValidator,
84 'min_length': validators.MinLengthValidator,
85 'max_length': validators.MaxLengthValidator,
86 'regex': validators.RegexValidator,
87 'required': IsNotEmptyValidator,
88 'prohibited': IsEmptyValidator,
89 }
91 def __init__(self, validation_rules=None):
92 self.validation_rules = validation_rules or {}
93 if type(self.validation_rules) is not dict:
94 raise ValueError(_("Validation rules must be passed as a dictionary"))
96 def __call__(self, instance, request=None):
97 """
98 Validate the instance and (optional) request against the validation rule(s).
99 """
100 for attr_path, rules in self.validation_rules.items():
102 # The rule applies to the current request
103 if attr_path.split('.')[0] == self.REQUEST_TOKEN:
104 # Skip if no request has been provided (we can't validate)
105 if request is None:
106 continue
107 attr = self._get_request_attr(request, attr_path)
108 # The rule applies to the instance
109 else:
110 attr = self._get_instance_attr(instance, attr_path)
112 # Validate the attribute's value against each of the rules defined for it
113 for descriptor, value in rules.items():
114 validator = self.get_validator(descriptor, value)
115 try:
116 validator(attr)
117 except ValidationError as exc:
118 raise ValidationError(
119 _("Custom validation failed for {attribute}: {exception}").format(
120 attribute=attr_path, exception=exc
121 )
122 )
124 # Execute custom validation logic (if any)
125 self.validate(instance, request)
127 @staticmethod
128 def _get_request_attr(request, name):
129 name = name.split('.', maxsplit=1)[1] # Remove token
130 try:
131 return operator.attrgetter(name)(request)
132 except AttributeError:
133 raise ValidationError(_('Invalid attribute "{name}" for request').format(name=name))
135 @staticmethod
136 def _get_instance_attr(instance, name):
137 # Attempt to resolve many-to-many fields to their stored values
138 m2m_fields = [f.name for f in instance._meta.local_many_to_many]
139 if name in m2m_fields:
140 if name in getattr(instance, '_m2m_values', []):
141 return instance._m2m_values[name]
142 if instance.pk:
143 return list(getattr(instance, name).all())
144 return []
146 # Raise a ValidationError for unknown attributes
147 try:
148 return operator.attrgetter(name)(instance)
149 except AttributeError:
150 raise ValidationError(_('Invalid attribute "{name}" for {model}').format(
151 name=name,
152 model=instance.__class__.__name__
153 ))
155 def get_validator(self, descriptor, value):
156 """
157 Instantiate and return the appropriate validator based on the descriptor given. For
158 example, 'min' returns MinValueValidator(value).
159 """
160 if descriptor not in self.VALIDATORS:
161 raise NotImplementedError(
162 f"Unknown validation type for {self.__class__.__name__}: '{descriptor}'"
163 )
164 validator_cls = self.VALIDATORS.get(descriptor)
165 return validator_cls(value)
167 def validate(self, instance, request):
168 """
169 Custom validation method, to be overridden by the user. Validation failures should
170 raise a ValidationError exception.
171 """
172 return
174 def fail(self, message, field=None):
175 """
176 Raise a ValidationError exception. Associate the provided message with a form/serializer field if specified.
177 """
178 if field is not None:
179 raise ValidationError({field: message})
180 raise ValidationError(message)