Compare commits

...

5 Commits

Author SHA1 Message Date
Martin Hauser
c2a581da47 fix(dcim): Render device height as rack units via floatformat
Use `TemplatedAttr` for device height and render using Django's
`floatformat` filter so 0.0 is displayed as `0U` (and whole-U values
omit the decimal).

Fixes #21267
2026-01-30 16:56:14 +01:00
Aditya Sharma
bec5ecf6a9 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
2026-01-30 13:48:38 +01:00
github-actions
c98f55dbd2 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 32s
CodeQL / Analyze (javascript-typescript) (push) Failing after 50s
CodeQL / Analyze (python) (push) Failing after 52s
2026-01-30 05:18:59 +00:00
Brian Tiemann
03853c3120 Merge branch 'main' into 20490-restrict-script-permissions
CI / build (20.x, 3.12) (push) Failing after 13s
CI / build (20.x, 3.13) (push) Failing after 16s
CI / build (20.x, 3.14) (push) Failing after 16s
2026-01-29 21:55:32 -05:00
Brian Tiemann
dc6a54ec21 Add filtering of Script objects based on object permissions with custom constraints 2026-01-18 17:23:51 -05:00
12 changed files with 139 additions and 52 deletions
+12 -3
View File
@@ -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.
+2 -1
View File
@@ -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:
+2 -2
View File
@@ -125,7 +125,7 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel):
class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
title = _('Dimensions')
height = attrs.TextAttr('device_type.u_height', format_string='{}U')
height = attrs.TemplatedAttr('device_type.u_height', template_name='dcim/devicetype/attrs/height.html')
total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')
@@ -135,7 +135,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
part_number = attrs.TextAttr('part_number')
default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
description = attrs.TextAttr('description')
height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height'))
height = attrs.TemplatedAttr('u_height', template_name='dcim/devicetype/attrs/height.html')
exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')
full_depth = attrs.BooleanAttr('is_full_depth')
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
+2 -1
View File
@@ -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():
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -103,7 +103,7 @@ class TextAttr(ObjectAttribute):
def get_value(self, obj):
value = resolve_attr_path(obj, self.accessor)
# Apply format string (if any)
if value and self.format_string:
if value is not None and value != '' and self.format_string:
return self.format_string.format(value)
return value
@@ -0,0 +1 @@
{{ value|floatformat }}U
+44 -35
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-29 05:16+0000\n"
"POT-Creation-Date: 2026-01-30 05:18+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -293,8 +293,8 @@ msgstr ""
#: netbox/circuits/filtersets.py:278 netbox/circuits/filtersets.py:382
#: netbox/circuits/filtersets.py:547 netbox/core/filtersets.py:85
#: netbox/core/filtersets.py:150 netbox/core/filtersets.py:176
#: netbox/core/filtersets.py:216 netbox/dcim/filtersets.py:810
#: netbox/core/filtersets.py:154 netbox/core/filtersets.py:180
#: netbox/core/filtersets.py:220 netbox/dcim/filtersets.py:810
#: netbox/dcim/filtersets.py:1568 netbox/dcim/filtersets.py:2692
#: netbox/extras/filtersets.py:48 netbox/extras/filtersets.py:71
#: netbox/extras/filtersets.py:101 netbox/extras/filtersets.py:142
@@ -763,7 +763,7 @@ msgstr ""
#: netbox/circuits/forms/filtersets.py:132
#: netbox/circuits/forms/filtersets.py:322
#: netbox/circuits/forms/filtersets.py:338 netbox/core/forms/filtersets.py:75
#: netbox/core/forms/filtersets.py:143 netbox/dcim/forms/bulk_edit.py:818
#: netbox/core/forms/filtersets.py:147 netbox/dcim/forms/bulk_edit.py:818
#: netbox/dcim/forms/bulk_import.py:480 netbox/dcim/forms/filtersets.py:199
#: netbox/dcim/forms/filtersets.py:232 netbox/dcim/forms/filtersets.py:1012
#: netbox/dcim/forms/filtersets.py:1155 netbox/dcim/forms/filtersets.py:1285
@@ -2189,7 +2189,7 @@ msgstr ""
msgid "Data source (name)"
msgstr ""
#: netbox/core/filtersets.py:186 netbox/dcim/filtersets.py:521
#: netbox/core/filtersets.py:190 netbox/dcim/filtersets.py:521
#: netbox/extras/filtersets.py:302 netbox/extras/filtersets.py:355
#: netbox/extras/filtersets.py:401 netbox/extras/filtersets.py:424
#: netbox/extras/filtersets.py:490 netbox/users/filtersets.py:31
@@ -2197,7 +2197,7 @@ msgstr ""
msgid "User (ID)"
msgstr ""
#: netbox/core/filtersets.py:192
#: netbox/core/filtersets.py:196
msgid "User name"
msgstr ""
@@ -2277,7 +2277,7 @@ msgstr ""
msgid "Creation"
msgstr ""
#: netbox/core/forms/filtersets.py:82 netbox/core/forms/filtersets.py:168
#: netbox/core/forms/filtersets.py:82 netbox/core/forms/filtersets.py:172
#: netbox/extras/forms/filtersets.py:577 netbox/extras/tables/tables.py:271
#: netbox/extras/tables/tables.py:338 netbox/extras/tables/tables.py:364
#: netbox/extras/tables/tables.py:383 netbox/extras/tables/tables.py:415
@@ -2288,39 +2288,44 @@ msgstr ""
msgid "Object Type"
msgstr ""
#: netbox/core/forms/filtersets.py:92
#: netbox/core/forms/filtersets.py:92 netbox/core/tables/jobs.py:46
#: netbox/templates/core/job.html:63 netbox/templates/core/rq_task.html:61
msgid "Queue"
msgstr ""
#: netbox/core/forms/filtersets.py:96
msgid "Created after"
msgstr ""
#: netbox/core/forms/filtersets.py:97
#: netbox/core/forms/filtersets.py:101
msgid "Created before"
msgstr ""
#: netbox/core/forms/filtersets.py:102
#: netbox/core/forms/filtersets.py:106
msgid "Scheduled after"
msgstr ""
#: netbox/core/forms/filtersets.py:107
#: netbox/core/forms/filtersets.py:111
msgid "Scheduled before"
msgstr ""
#: netbox/core/forms/filtersets.py:112
#: netbox/core/forms/filtersets.py:116
msgid "Started after"
msgstr ""
#: netbox/core/forms/filtersets.py:117
#: netbox/core/forms/filtersets.py:121
msgid "Started before"
msgstr ""
#: netbox/core/forms/filtersets.py:122
#: netbox/core/forms/filtersets.py:126
msgid "Completed after"
msgstr ""
#: netbox/core/forms/filtersets.py:127
#: netbox/core/forms/filtersets.py:131
msgid "Completed before"
msgstr ""
#: netbox/core/forms/filtersets.py:134 netbox/core/forms/filtersets.py:163
#: netbox/core/forms/filtersets.py:138 netbox/core/forms/filtersets.py:167
#: netbox/dcim/forms/bulk_edit.py:455 netbox/dcim/forms/filtersets.py:509
#: netbox/dcim/forms/model_forms.py:326 netbox/extras/forms/filtersets.py:572
#: netbox/extras/forms/filtersets.py:592 netbox/extras/tables/tables.py:391
@@ -2336,22 +2341,22 @@ msgstr ""
msgid "User"
msgstr ""
#: netbox/core/forms/filtersets.py:142 netbox/core/tables/change_logging.py:15
#: netbox/core/tables/jobs.py:69 netbox/extras/tables/tables.py:773
#: netbox/core/forms/filtersets.py:146 netbox/core/tables/change_logging.py:15
#: netbox/core/tables/jobs.py:72 netbox/extras/tables/tables.py:773
#: netbox/extras/tables/tables.py:828
#: netbox/templates/core/objectchange.html:32
msgid "Time"
msgstr ""
#: netbox/core/forms/filtersets.py:147 netbox/extras/forms/filtersets.py:561
#: netbox/core/forms/filtersets.py:151 netbox/extras/forms/filtersets.py:561
msgid "After"
msgstr ""
#: netbox/core/forms/filtersets.py:152 netbox/extras/forms/filtersets.py:566
#: netbox/core/forms/filtersets.py:156 netbox/extras/forms/filtersets.py:566
msgid "Before"
msgstr ""
#: netbox/core/forms/filtersets.py:156 netbox/core/tables/change_logging.py:29
#: netbox/core/forms/filtersets.py:160 netbox/core/tables/change_logging.py:29
#: netbox/extras/forms/model_forms.py:484
#: netbox/templates/core/objectchange.html:46
#: netbox/templates/extras/eventrule.html:71
@@ -2721,28 +2726,36 @@ msgid "job ID"
msgstr ""
#: netbox/core/models/jobs.py:116
msgid "queue name"
msgstr ""
#: netbox/core/models/jobs.py:119
msgid "Name of the queue in which this job was enqueued"
msgstr ""
#: netbox/core/models/jobs.py:122
msgid "log entries"
msgstr ""
#: netbox/core/models/jobs.py:132
#: netbox/core/models/jobs.py:138
msgid "job"
msgstr ""
#: netbox/core/models/jobs.py:133
#: netbox/core/models/jobs.py:139
msgid "jobs"
msgstr ""
#: netbox/core/models/jobs.py:163
#: netbox/core/models/jobs.py:169
#, python-brace-format
msgid "Jobs cannot be assigned to this object type ({type})."
msgstr ""
#: netbox/core/models/jobs.py:216
#: netbox/core/models/jobs.py:226
#, python-brace-format
msgid "Invalid status for job termination. Choices are: {choices}"
msgstr ""
#: netbox/core/models/jobs.py:273
#: netbox/core/models/jobs.py:283
msgid ""
"enqueue() cannot be called with values for both schedule_at and immediate."
msgstr ""
@@ -2788,7 +2801,7 @@ msgstr ""
msgid "Request ID"
msgstr ""
#: netbox/core/tables/change_logging.py:45 netbox/core/tables/jobs.py:76
#: netbox/core/tables/change_logging.py:45 netbox/core/tables/jobs.py:79
#: netbox/extras/tables/tables.py:784 netbox/extras/tables/tables.py:841
#: netbox/templates/core/objectchange.html:68
msgid "Message"
@@ -2831,16 +2844,16 @@ msgstr ""
msgid "Interval"
msgstr ""
#: netbox/core/tables/jobs.py:46
#: netbox/core/tables/jobs.py:49
msgid "Log Entries"
msgstr ""
#: netbox/core/tables/jobs.py:73 netbox/extras/tables/tables.py:778
#: netbox/core/tables/jobs.py:76 netbox/extras/tables/tables.py:778
#: netbox/extras/tables/tables.py:832
msgid "Level"
msgstr ""
#: netbox/core/tables/jobs.py:80
#: netbox/core/tables/jobs.py:83
msgid "No log entries"
msgstr ""
@@ -8873,7 +8886,7 @@ msgstr ""
#: netbox/extras/forms/filtersets.py:176 netbox/extras/forms/filtersets.py:377
#: netbox/extras/forms/filtersets.py:400 netbox/extras/forms/filtersets.py:496
#: netbox/extras/forms/model_forms.py:690 netbox/templates/core/job.html:69
#: netbox/extras/forms/model_forms.py:690 netbox/templates/core/job.html:73
#: netbox/templates/extras/eventrule.html:84
msgid "Data"
msgstr ""
@@ -13574,10 +13587,6 @@ msgstr ""
msgid "Enqueue"
msgstr ""
#: netbox/templates/core/rq_task.html:61
msgid "Queue"
msgstr ""
#: netbox/templates/core/rq_task.html:65
msgid "Timeout"
msgstr ""
+9 -7
View File
@@ -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'] = []
+14
View File
@@ -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
+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
@@ -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')])
+23
View File
@@ -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=[]), [])