Repository: netbox-community/netbox

Issue:
Enable accessing related object attributes via dotted path when defining custom validation rules
### NetBox version

v3.7.4

### Feature type

New functionality

### Proposed functionality

When defining [custom validation rules](https://docs.netbox.dev/en/stable/customization/custom-validation/#custom-validation-rules) as JSON, it is currently possible to access only direct attributes on an object. For example:

```python
CUSTOM_VALIDATORS = {
    "dcim.site": [
        {
            "name": {
                "min_length": 5,
            }
        }
    ]
}
```

It is not possible to reference an attribute of a related object; this would require creating a CustomValidator subclass in Python with a custom `validate()` method as discussed [here](https://github.com/netbox-community/netbox/discussions/8950#discussioncomment-2425489).

This issue proposes a mechanism for referencing attributes of related objects, identified by a dotted path. For example, the following would reference a the name of a site's assigned region:

```python
CUSTOM_VALIDATORS = {
    "dcim.site": [
        {
            "region.name": {
                "eq": "North Carolina",
            }
        }
    ]
}
```

This could be achieved by employing Python's [`operator.attrgetter()`](https://docs.python.org/3/library/operator.html#operator.attrgetter) utility.

### Use case

This enhancement will allow users to reach "deeper" into an object when validating it without the need to write a custom subclass in Python.

### Database changes

None

### External dependencies

None


Discussion:
I may end up implementing this as a byproduct of #14279 as the basic functionality will be required there as well.

Resolving patch:
diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py
--- a/netbox/extras/validators.py
+++ b/netbox/extras/validators.py
@@ -151,14 +151,14 @@ def _get_instance_attr(instance, name):
             return []
 
         # Raise a ValidationError for unknown attributes
-        if not hasattr(instance, name):
+        try:
+            return operator.attrgetter(name)(instance)
+        except AttributeError:
             raise ValidationError(_('Invalid attribute "{name}" for {model}').format(
                 name=name,
                 model=instance.__class__.__name__
             ))
 
-        return getattr(instance, name)
-
     def get_validator(self, descriptor, value):
         """
         Instantiate and return the appropriate validator based on the descriptor given. For


Test patch:
diff --git a/netbox/extras/tests/test_customvalidation.py b/netbox/extras/tests/test_customvalidators.py
similarity index 91%
rename from netbox/extras/tests/test_customvalidation.py
rename to netbox/extras/tests/test_customvalidators.py
--- a/netbox/extras/tests/test_customvalidation.py
+++ b/netbox/extras/tests/test_customvalidators.py
@@ -4,7 +4,7 @@
 from django.test import TestCase, override_settings
 
 from dcim.choices import SiteStatusChoices
-from dcim.models import Site
+from dcim.models import Site, Region
 from extras.validators import CustomValidator
 from ipam.models import ASN, RIR
 from users.models import User
@@ -82,6 +82,13 @@ def validate(self, instance):
 })
 
 
+region_validator = CustomValidator({
+    'region.name': {
+        'eq': 'Bar',
+    }
+})
+
+
 request_validator = CustomValidator({
     'request.user.username': {
         'eq': 'Bob'
@@ -154,6 +161,20 @@ def test_prohibited(self):
     def test_valid(self):
         Site(name='abcdef123', slug='abcdef123').clean()
 
+    @override_settings(CUSTOM_VALIDATORS={'dcim.site': [region_validator]})
+    def test_valid(self):
+        region1 = Region(name='Foo', slug='foo')
+        region1.save()
+        region2 = Region(name='Bar', slug='bar')
+        region2.save()
+
+        # Invalid region
+        with self.assertRaises(ValidationError):
+            Site(name='abcdef123', slug='abcdef123', region=region1).clean()
+
+        # Valid region
+        Site(name='abcdef123', slug='abcdef123', region=region2).clean()
+
     @override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]})
     def test_custom_invalid(self):
         with self.assertRaises(ValidationError):
@@ -207,7 +228,7 @@ def test_plain_data(self):
     @override_settings(
         CUSTOM_VALIDATORS={
             'dcim.site': (
-                'extras.tests.test_customvalidation.MyValidator',
+                'extras.tests.test_customvalidators.MyValidator',
             )
         }
     )
@@ -254,7 +275,7 @@ def test_plain_data(self):
     @override_settings(
         PROTECTION_RULES={
             'dcim.site': (
-                'extras.tests.test_customvalidation.MyValidator',
+                'extras.tests.test_customvalidators.MyValidator',
             )
         }
     )