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