Kaynağa Gözat

Closes #7619: Permit custom validation rules to be defined as plain data or dotted path to class

jeremystretch 4 yıl önce
ebeveyn
işleme
3292a2aecc

+ 61 - 29
docs/customization/custom-validation.md

@@ -1,22 +1,18 @@
 # Custom Validation
 # Custom Validation
 
 
-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.
+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 custom validation rules.
 
 
-## CustomValidator
+## Custom Validation Rules
 
 
-### Validation Rules
+Custom validation rules are expressed as a mapping of model attributes to a set of rules to which that attribute must conform. For example:
 
 
-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:
-
-```python
-from extras.validators import CustomValidator
-
-CustomValidator({
-    'name': {
-        'min_length': 5,
-        'max_length': 30,
-    }
-})
+```json
+{
+  "name": {
+    "min_length": 5,
+    "max_length": 30
+  }
+}
 ```
 ```
 
 
 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.
 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.
@@ -38,12 +34,13 @@ The `min` and `max` types should be defined for numeric values, whereas `min_len
 
 
 ### Custom Validation Logic
 ### Custom Validation Logic
 
 
-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.
+There may be instances where the provided validation types are insufficient. NetBox provides a `CustomValidator` class which can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected.
 
 
 ```python
 ```python
 from extras.validators import CustomValidator
 from extras.validators import CustomValidator
 
 
 class MyValidator(CustomValidator):
 class MyValidator(CustomValidator):
+
     def validate(self, instance):
     def validate(self, instance):
         if instance.status == 'active' and not instance.description:
         if instance.status == 'active' and not instance.description:
             self.fail("Active sites must have a description set!", field='status')
             self.fail("Active sites must have a description set!", field='status')
@@ -53,34 +50,69 @@ The `fail()` method may optionally specify a field with which to associate the s
 
 
 ## Assigning Custom Validators
 ## Assigning Custom Validators
 
 
-Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter, as such:
+Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined:
+
+1. Plain JSON mapping (no custom logic)
+2. Dotted path to a custom validator class
+3. Direct reference to a custom validator class
+
+### Plain Data
+
+For cases where custom logic is not needed, it is sufficient to pass validation rules as plain JSON-compatible objects. This approach typically affords the most portability for your configuration. For instance:
+
+```python
+CUSTOM_VALIDATORS = {
+    "dcim.site": [
+        {
+            "name": {
+                "min_length": 5,
+                "max_length": 30,
+            }
+        }
+    ],
+    "dcim.device": [
+        {
+            "platform": {
+                "required": True,
+            }
+        }
+    ]
+}
+```
+
+### Dotted Path
+
+In instances where a custom validator class is needed, it can be referenced by its Python path (relative to NetBox's working directory):
 
 
 ```python
 ```python
 CUSTOM_VALIDATORS = {
 CUSTOM_VALIDATORS = {
     'dcim.site': (
     'dcim.site': (
-        Validator1,
-        Validator2,
-        Validator3
+        'my_validators.Validator1',
+        'my_validators.Validator2',
+    ),
+    'dcim.device': (
+        'my_validators.Validator3',
     )
     )
 }
 }
 ```
 ```
 
 
-!!! note
-    Even if defining only a single validator, it must be passed as an iterable.
+### Direct Class Reference
 
 
-When it is not necessary to define a custom `validate()` method, you may opt to pass a `CustomValidator` instance directly:
+This approach requires each class being instantiated to be imported directly within the Python configuration file.
 
 
 ```python
 ```python
-from extras.validators import CustomValidator
+from my_validators import Validator1, Validator2, Validator3
 
 
 CUSTOM_VALIDATORS = {
 CUSTOM_VALIDATORS = {
     'dcim.site': (
     'dcim.site': (
-        CustomValidator({
-            'name': {
-                'min_length': 5,
-                'max_length': 30,
-            }
-        }),
+        Validator1,
+        Validator2,
+    ),
+    'dcim.device': (
+        Validator3,
     )
     )
 }
 }
 ```
 ```
+
+!!! note
+    Even if defining only a single validator, it must be passed as an iterable.

+ 4 - 0
docs/release-notes/version-3.1.md

@@ -1,5 +1,9 @@
 ## v3.1-beta2 (FUTURE)
 ## v3.1-beta2 (FUTURE)
 
 
+### Enhancements
+
+* [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class
+
 ### Bug Fixes
 ### Bug Fixes
 
 
 * [#7756](https://github.com/netbox-community/netbox/issues/7756) - Fix AttributeError exception when editing an IP address assigned to a FHRPGroup
 * [#7756](https://github.com/netbox-community/netbox/issues/7756) - Fix AttributeError exception when editing an IP address assigned to a FHRPGroup

+ 13 - 0
netbox/extras/signals.py

@@ -1,3 +1,4 @@
+import importlib
 import logging
 import logging
 
 
 from django.conf import settings
 from django.conf import settings
@@ -6,6 +7,7 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.dispatch import receiver, Signal
 from django.dispatch import receiver, Signal
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 
+from extras.validators import CustomValidator
 from netbox.signals import post_clean
 from netbox.signals import post_clean
 from .choices import ObjectChangeActionChoices
 from .choices import ObjectChangeActionChoices
 from .models import ConfigRevision, CustomField, ObjectChange
 from .models import ConfigRevision, CustomField, ObjectChange
@@ -159,7 +161,18 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
 def run_custom_validators(sender, instance, **kwargs):
 def run_custom_validators(sender, instance, **kwargs):
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
     validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
     validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
+
     for validator in validators:
     for validator in validators:
+
+        # Loading a validator class by dotted path
+        if type(validator) is str:
+            module, cls = validator.rsplit('.', 1)
+            validator = getattr(importlib.import_module(module), cls)()
+
+        # Constructing a new instance on the fly from a ruleset
+        elif type(validator) is dict:
+            validator = CustomValidator(validator)
+
         validator(instance)
         validator(instance)
 
 
 
 

+ 35 - 0
netbox/extras/tests/test_customvalidator.py

@@ -119,3 +119,38 @@ class CustomValidatorTest(TestCase):
     @override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]})
     @override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]})
     def test_custom_valid(self):
     def test_custom_valid(self):
         Site(name='foo', slug='foo').clean()
         Site(name='foo', slug='foo').clean()
+
+
+class CustomValidatorConfigTest(TestCase):
+
+    @override_settings(
+        CUSTOM_VALIDATORS={
+            'dcim.site': [
+                {'name': {'min_length': 5}}
+            ]
+        }
+    )
+    def test_plain_data(self):
+        """
+        Test custom validator configuration using plain data (as opposed to a CustomValidator
+        class)
+        """
+        with self.assertRaises(ValidationError):
+            Site(name='abcd', slug='abcd').clean()
+        Site(name='abcde', slug='abcde').clean()
+
+    @override_settings(
+        CUSTOM_VALIDATORS={
+            'dcim.site': (
+                'extras.tests.test_customvalidator.MyValidator',
+            )
+        }
+    )
+    def test_dotted_path(self):
+        """
+        Test custom validator configuration using a dotted path (string) reference to a
+        CustomValidator class.
+        """
+        Site(name='foo', slug='foo').clean()
+        with self.assertRaises(ValidationError):
+            Site(name='bar', slug='bar').clean()