From bec5ecf6a905945aa9ee2572f74dfe14b003b228 Mon Sep 17 00:00:00 2001 From: Aditya Sharma <100428589+adionit7@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:18:38 +0530 Subject: [PATCH] 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 --- docs/configuration/data-validation.md | 15 +++++++++++--- netbox/core/signals.py | 3 ++- netbox/extras/dashboard/widgets.py | 3 ++- netbox/extras/signals.py | 3 ++- netbox/utilities/choices.py | 16 ++++++++------- netbox/utilities/data.py | 14 +++++++++++++ netbox/utilities/tests/test_choices.py | 28 +++++++++++++++++++++++++- netbox/utilities/tests/test_data.py | 23 +++++++++++++++++++++ 8 files changed, 91 insertions(+), 14 deletions(-) diff --git a/docs/configuration/data-validation.md b/docs/configuration/data-validation.md index 9988f6e0b..2f00814f3 100644 --- a/docs/configuration/data-validation.md +++ b/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. diff --git a/netbox/core/signals.py b/netbox/core/signals.py index d918d2389..6316cfd01 100644 --- a/netbox/core/signals.py +++ b/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: diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 935e48051..39bfcb13d 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/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(): diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index aa4608f1a..e079abd17 100644 --- a/netbox/extras/signals.py +++ b/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) diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 5eaa28b41..80151e807 100644 --- a/netbox/utilities/choices.py +++ b/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'] = [] diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py index 36fd0f7fc..549bf96ef 100644 --- a/netbox/utilities/data.py +++ b/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 diff --git a/netbox/utilities/tests/test_choices.py b/netbox/utilities/tests/test_choices.py index 8dbf5d602..2bfd8fd5d 100644 --- a/netbox/utilities/tests/test_choices.py +++ b/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')]) diff --git a/netbox/utilities/tests/test_data.py b/netbox/utilities/tests/test_data.py index 4e0942e2a..7d75d9085 100644 --- a/netbox/utilities/tests/test_data.py +++ b/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=[]), [])