Files
netbox/netbox/utilities/choices.py
Aditya Sharma bec5ecf6a9 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
2026-01-30 13:48:38 +01:00

119 lines
3.8 KiB
Python

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__ = (
'ChoiceSet',
'unpack_grouped_choices',
)
class ChoiceSetMeta(type):
"""
Metaclass for ChoiceSet
"""
def __new__(mcs, name, bases, attrs):
# Extend static choices with any configured choices
if key := attrs.get('key'):
assert type(attrs['CHOICES']) is list, _(
"{name} has a key defined but CHOICES is not a list"
).format(name=name)
app = attrs['__module__'].split('.', 1)[0]
replace_key = f'{app}.{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'] = []
attrs['colors'] = {}
for choice in attrs['CHOICES']:
if isinstance(choice[1], (list, tuple)):
grouped_choices = []
for c in choice[1]:
grouped_choices.append((c[0], c[1]))
if len(c) == 3:
attrs['colors'][c[0]] = c[2]
attrs['_choices'].append((choice[0], grouped_choices))
else:
attrs['_choices'].append((choice[0], choice[1]))
if len(choice) == 3:
attrs['colors'][choice[0]] = choice[2]
return super().__new__(mcs, name, bases, attrs)
def __call__(cls, *args, **kwargs):
# django-filters will check if a 'choices' value is callable, and if so assume that it returns an iterable
return getattr(cls, '_choices', ())
def __iter__(cls):
return iter(getattr(cls, '_choices', ()))
class ChoiceSet(metaclass=ChoiceSetMeta):
"""
Holds an iterable of choice tuples suitable for passing to a Django model or form field. Choices can be defined
statically within the class as CHOICES and/or gleaned from the FIELD_CHOICES configuration parameter.
"""
CHOICES = list()
@classmethod
def values(cls):
return [c[0] for c in unpack_grouped_choices(cls._choices)]
@classmethod
def as_enum(cls, name=None, prefix=''):
"""
Return the ChoiceSet as an Enum. If no name is provided, "Choices" will be stripped from the class name (if
present) and "Enum" will be appended. For example, "CircuitStatusChoices" will become "CircuitStatusEnum".
"""
name = name or f"{cls.__name__.split('Choices')[0]}Enum"
prefix = f'{prefix}_' if prefix else ''
data = {f'{prefix}{enum_key(v)}'.upper(): v for v in cls.values()}
return enum.Enum(name, data)
def unpack_grouped_choices(choices):
"""
Unpack a grouped choices hierarchy into a flat list of two-tuples. For example:
choices = (
('Foo', (
(1, 'A'),
(2, 'B')
)),
('Bar', (
(3, 'C'),
(4, 'D')
))
)
becomes:
choices = (
(1, 'A'),
(2, 'B'),
(3, 'C'),
(4, 'D')
)
"""
unpacked_choices = []
for key, value in choices:
if isinstance(value, (list, tuple)):
# Entered an optgroup
for optgroup_key, optgroup_value in value:
unpacked_choices.append((optgroup_key, optgroup_value))
else:
unpacked_choices.append((key, value))
return unpacked_choices