Просмотр исходного кода

Closes #21209: Accept case-insensitive model names in configuration (#21275)

NetBox now accepts case-insensitive model identifiers in configuration, allowing
both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") for
DEFAULT_DASHBOARD, CUSTOM_VALIDATORS, and PROTECTION_RULES.
This makes model name handling consistent with FIELD_CHOICES.

- Add a shared case-insensitive config lookup helper (get_config_value_ci())
- Use the helper in extras/signals.py and core/signals.py
- Update FIELD_CHOICES ChoiceSetMeta to support case-insensitive replace/extend
  (only compute extend choices if no replacement is defined)
- Add unit tests for get_config_value_ci()
- Add integration tests for case-insensitive FIELD_CHOICES replacement/extension
- Update documentation examples to use PascalCase consistently
Aditya Sharma 2 недель назад
Родитель
Сommit
bec5ecf6a9

+ 12 - 3
docs/configuration/data-validation.md

@@ -8,7 +8,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
 
 ```python
 CUSTOM_VALIDATORS = {
-    "dcim.site": [
+    "dcim.Site": [
         {
             "name": {
                 "min_length": 5,
@@ -17,12 +17,15 @@ CUSTOM_VALIDATORS = {
         },
         "my_plugin.validators.Validator1"
     ],
-    "dcim.device": [
+    "dcim.Device": [
         "my_plugin.validators.Validator1"
     ]
 }
 ```
 
+!!! info "Case-Insensitive Model Names"
+    Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
+
 ---
 
 ## FIELD_CHOICES
@@ -53,6 +56,9 @@ FIELD_CHOICES = {
 }
 ```
 
+!!! info "Case-Insensitive Field Identifiers"
+    Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
+
 The following model fields support configurable choices:
 
 * `circuits.Circuit.status`
@@ -98,7 +104,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
 
 ```python
 PROTECTION_RULES = {
-    "dcim.site": [
+    "dcim.Site": [
         {
             "status": {
                 "eq": "decommissioning"
@@ -108,3 +114,6 @@ PROTECTION_RULES = {
     ]
 }
 ```
+
+!!! info "Case-Insensitive Model Names"
+    Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.

+ 2 - 1
netbox/core/signals.py

@@ -18,6 +18,7 @@ from extras.events import enqueue_event
 from extras.models import Tag
 from extras.utils import run_validators
 from netbox.config import get_config
+from utilities.data import get_config_value_ci
 from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
 from utilities.exceptions import AbortRequest
@@ -168,7 +169,7 @@ def handle_deleted_object(sender, instance, **kwargs):
     # to queueing any events for the object being deleted, in case a validation error is
     # raised, causing the deletion to fail.
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
-    validators = get_config().PROTECTION_RULES.get(model_name, [])
+    validators = get_config_value_ci(get_config().PROTECTION_RULES, model_name, default=[])
     try:
         run_validators(instance, validators)
     except ValidationError as e:

+ 2 - 1
netbox/extras/dashboard/widgets.py

@@ -75,10 +75,11 @@ def get_bookmarks_object_type_choices():
 def get_models_from_content_types(content_types):
     """
     Return a list of models corresponding to the given content types, identified by natural key.
+    Accepts both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") model names.
     """
     models = []
     for content_type_id in content_types:
-        app_label, model_name = content_type_id.split('.')
+        app_label, model_name = content_type_id.lower().split('.')
         try:
             content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
             if content_type.model_class():

+ 2 - 1
netbox/extras/signals.py

@@ -9,6 +9,7 @@ from extras.models import EventRule, Notification, Subscription
 from netbox.config import get_config
 from netbox.models.features import has_feature
 from netbox.signals import post_clean
+from utilities.data import get_config_value_ci
 from utilities.exceptions import AbortRequest
 from .models import CustomField, TaggedItem
 from .utils import run_validators
@@ -65,7 +66,7 @@ def run_save_validators(sender, instance, **kwargs):
     Run any custom validation rules for the model prior to calling save().
     """
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
-    validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
+    validators = get_config_value_ci(get_config().CUSTOM_VALIDATORS, model_name, default=[])
 
     run_validators(instance, validators)
 

+ 9 - 7
netbox/utilities/choices.py

@@ -3,6 +3,7 @@ import enum
 from django.conf import settings
 from django.utils.translation import gettext_lazy as _
 
+from utilities.data import get_config_value_ci
 from utilities.string import enum_key
 
 __all__ = (
@@ -24,13 +25,14 @@ class ChoiceSetMeta(type):
             ).format(name=name)
             app = attrs['__module__'].split('.', 1)[0]
             replace_key = f'{app}.{key}'
-            extend_key = f'{replace_key}+' if replace_key else None
-            if replace_key and replace_key in settings.FIELD_CHOICES:
-                # Replace the stock choices
-                attrs['CHOICES'] = settings.FIELD_CHOICES[replace_key]
-            elif extend_key and extend_key in settings.FIELD_CHOICES:
-                # Extend the stock choices
-                attrs['CHOICES'].extend(settings.FIELD_CHOICES[extend_key])
+            replace_choices = get_config_value_ci(settings.FIELD_CHOICES, replace_key)
+            if replace_choices is not None:
+                attrs['CHOICES'] = replace_choices
+            else:
+                extend_key = f'{replace_key}+'
+                extend_choices = get_config_value_ci(settings.FIELD_CHOICES, extend_key)
+                if extend_choices is not None:
+                    attrs['CHOICES'].extend(extend_choices)
 
         # Define choice tuples and color maps
         attrs['_choices'] = []

+ 14 - 0
netbox/utilities/data.py

@@ -10,6 +10,7 @@ __all__ = (
     'deepmerge',
     'drange',
     'flatten_dict',
+    'get_config_value_ci',
     'ranges_to_string',
     'ranges_to_string_list',
     'resolve_attr_path',
@@ -22,6 +23,19 @@ __all__ = (
 # Dictionary utilities
 #
 
+def get_config_value_ci(config_dict, key, default=None):
+    """
+    Retrieve a value from a dictionary using case-insensitive key matching.
+    """
+    if key in config_dict:
+        return config_dict[key]
+    key_lower = key.lower()
+    for config_key, value in config_dict.items():
+        if config_key.lower() == key_lower:
+            return value
+    return default
+
+
 def deepmerge(original, new):
     """
     Deep merge two dictionaries (new into original) and return a new dict

+ 27 - 1
netbox/utilities/tests/test_choices.py

@@ -1,4 +1,4 @@
-from django.test import TestCase
+from django.test import TestCase, override_settings
 
 from utilities.choices import ChoiceSet
 
@@ -30,3 +30,29 @@ class ChoiceSetTestCase(TestCase):
 
     def test_values(self):
         self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3])
+
+
+class FieldChoicesCaseInsensitiveTestCase(TestCase):
+    """
+    Integration tests for FIELD_CHOICES case-insensitive key lookup.
+    """
+
+    def test_replace_choices_with_different_casing(self):
+        """Test that replacement works when config key casing differs."""
+        # Config uses lowercase, but code constructs PascalCase key
+        with override_settings(FIELD_CHOICES={'utilities.teststatus': [('new', 'New')]}):
+            class TestStatusChoices(ChoiceSet):
+                key = 'TestStatus'  # Code will look up 'utilities.TestStatus'
+                CHOICES = [('old', 'Old')]
+
+            self.assertEqual(TestStatusChoices.CHOICES, [('new', 'New')])
+
+    def test_extend_choices_with_different_casing(self):
+        """Test that extension works with the + suffix under casing differences."""
+        # Config uses lowercase with + suffix
+        with override_settings(FIELD_CHOICES={'utilities.teststatus+': [('extra', 'Extra')]}):
+            class TestStatusChoices(ChoiceSet):
+                key = 'TestStatus'  # Code will look up 'utilities.TestStatus+'
+                CHOICES = [('base', 'Base')]
+
+            self.assertEqual(TestStatusChoices.CHOICES, [('base', 'Base'), ('extra', 'Extra')])

+ 23 - 0
netbox/utilities/tests/test_data.py

@@ -2,6 +2,7 @@ from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.test import TestCase
 from utilities.data import (
     check_ranges_overlap,
+    get_config_value_ci,
     ranges_to_string,
     ranges_to_string_list,
     string_to_ranges,
@@ -96,3 +97,25 @@ class RangeFunctionsTestCase(TestCase):
             string_to_ranges('2-10, a-b'),
             None  # Fails to convert
         )
+
+
+class GetConfigValueCITestCase(TestCase):
+
+    def test_exact_match(self):
+        config = {'dcim.site': 'value1', 'dcim.Device': 'value2'}
+        self.assertEqual(get_config_value_ci(config, 'dcim.site'), 'value1')
+        self.assertEqual(get_config_value_ci(config, 'dcim.Device'), 'value2')
+
+    def test_case_insensitive_match(self):
+        config = {'dcim.Site': 'value1', 'ipam.IPAddress': 'value2'}
+        self.assertEqual(get_config_value_ci(config, 'dcim.site'), 'value1')
+        self.assertEqual(get_config_value_ci(config, 'ipam.ipaddress'), 'value2')
+
+    def test_default_value(self):
+        config = {'dcim.site': 'value1'}
+        self.assertIsNone(get_config_value_ci(config, 'nonexistent'))
+        self.assertEqual(get_config_value_ci(config, 'nonexistent', default=[]), [])
+
+    def test_empty_dict(self):
+        self.assertIsNone(get_config_value_ci({}, 'any.key'))
+        self.assertEqual(get_config_value_ci({}, 'any.key', default=[]), [])