Closes #21209: Accept case-insensitive model names in configuration (#21275)
CI / build (20.x, 3.12) (push) Failing after 9s
CI / build (20.x, 3.13) (push) Failing after 8s
CI / build (20.x, 3.14) (push) Failing after 8s
CodeQL / Analyze (actions) (push) Failing after 25s
CodeQL / Analyze (javascript-typescript) (push) Failing after 35s
CodeQL / Analyze (python) (push) Failing after 37s

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
This commit is contained in:
Aditya Sharma
2026-01-30 18:18:38 +05:30
committed by GitHub
parent c98f55dbd2
commit bec5ecf6a9
8 changed files with 91 additions and 14 deletions
+12 -3
View File
@@ -8,7 +8,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
```python ```python
CUSTOM_VALIDATORS = { CUSTOM_VALIDATORS = {
"dcim.site": [ "dcim.Site": [
{ {
"name": { "name": {
"min_length": 5, "min_length": 5,
@@ -17,12 +17,15 @@ CUSTOM_VALIDATORS = {
}, },
"my_plugin.validators.Validator1" "my_plugin.validators.Validator1"
], ],
"dcim.device": [ "dcim.Device": [
"my_plugin.validators.Validator1" "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 ## 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: The following model fields support configurable choices:
* `circuits.Circuit.status` * `circuits.Circuit.status`
@@ -98,7 +104,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
```python ```python
PROTECTION_RULES = { PROTECTION_RULES = {
"dcim.site": [ "dcim.Site": [
{ {
"status": { "status": {
"eq": "decommissioning" "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
View File
@@ -18,6 +18,7 @@ from extras.events import enqueue_event
from extras.models import Tag from extras.models import Tag
from extras.utils import run_validators from extras.utils import run_validators
from netbox.config import get_config from netbox.config import get_config
from utilities.data import get_config_value_ci
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
from utilities.exceptions import AbortRequest 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 # to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail. # raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' 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: try:
run_validators(instance, validators) run_validators(instance, validators)
except ValidationError as e: except ValidationError as e:
+2 -1
View File
@@ -75,10 +75,11 @@ def get_bookmarks_object_type_choices():
def get_models_from_content_types(content_types): def get_models_from_content_types(content_types):
""" """
Return a list of models corresponding to the given content types, identified by natural key. 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 = [] models = []
for content_type_id in content_types: 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: try:
content_type = ObjectType.objects.get_by_natural_key(app_label, model_name) content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
if content_type.model_class(): if content_type.model_class():
+2 -1
View File
@@ -9,6 +9,7 @@ from extras.models import EventRule, Notification, Subscription
from netbox.config import get_config from netbox.config import get_config
from netbox.models.features import has_feature from netbox.models.features import has_feature
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.data import get_config_value_ci
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from .models import CustomField, TaggedItem from .models import CustomField, TaggedItem
from .utils import run_validators 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(). Run any custom validation rules for the model prior to calling save().
""" """
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' 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) run_validators(instance, validators)
+9 -7
View File
@@ -3,6 +3,7 @@ import enum
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from utilities.data import get_config_value_ci
from utilities.string import enum_key from utilities.string import enum_key
__all__ = ( __all__ = (
@@ -24,13 +25,14 @@ class ChoiceSetMeta(type):
).format(name=name) ).format(name=name)
app = attrs['__module__'].split('.', 1)[0] app = attrs['__module__'].split('.', 1)[0]
replace_key = f'{app}.{key}' replace_key = f'{app}.{key}'
extend_key = f'{replace_key}+' if replace_key else None replace_choices = get_config_value_ci(settings.FIELD_CHOICES, replace_key)
if replace_key and replace_key in settings.FIELD_CHOICES: if replace_choices is not None:
# Replace the stock choices attrs['CHOICES'] = replace_choices
attrs['CHOICES'] = settings.FIELD_CHOICES[replace_key] else:
elif extend_key and extend_key in settings.FIELD_CHOICES: extend_key = f'{replace_key}+'
# Extend the stock choices extend_choices = get_config_value_ci(settings.FIELD_CHOICES, extend_key)
attrs['CHOICES'].extend(settings.FIELD_CHOICES[extend_key]) if extend_choices is not None:
attrs['CHOICES'].extend(extend_choices)
# Define choice tuples and color maps # Define choice tuples and color maps
attrs['_choices'] = [] attrs['_choices'] = []
+14
View File
@@ -10,6 +10,7 @@ __all__ = (
'deepmerge', 'deepmerge',
'drange', 'drange',
'flatten_dict', 'flatten_dict',
'get_config_value_ci',
'ranges_to_string', 'ranges_to_string',
'ranges_to_string_list', 'ranges_to_string_list',
'resolve_attr_path', 'resolve_attr_path',
@@ -22,6 +23,19 @@ __all__ = (
# Dictionary utilities # 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): def deepmerge(original, new):
""" """
Deep merge two dictionaries (new into original) and return a new dict Deep merge two dictionaries (new into original) and return a new dict
+27 -1
View File
@@ -1,4 +1,4 @@
from django.test import TestCase from django.test import TestCase, override_settings
from utilities.choices import ChoiceSet from utilities.choices import ChoiceSet
@@ -30,3 +30,29 @@ class ChoiceSetTestCase(TestCase):
def test_values(self): def test_values(self):
self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3]) 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
View File
@@ -2,6 +2,7 @@ from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase from django.test import TestCase
from utilities.data import ( from utilities.data import (
check_ranges_overlap, check_ranges_overlap,
get_config_value_ci,
ranges_to_string, ranges_to_string,
ranges_to_string_list, ranges_to_string_list,
string_to_ranges, string_to_ranges,
@@ -96,3 +97,25 @@ class RangeFunctionsTestCase(TestCase):
string_to_ranges('2-10, a-b'), string_to_ranges('2-10, a-b'),
None # Fails to convert 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=[]), [])