mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-19 23:41:35 +02:00
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:
@@ -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'] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')])
|
||||
|
||||
@@ -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=[]), [])
|
||||
|
||||
Reference in New Issue
Block a user