From 7ff7c6d17e93d04fa83955ccf323ad1eb2c80df5 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Tue, 7 Apr 2026 18:52:36 +0200 Subject: [PATCH] feat(ui): Add colored rendering for related object attributes Introduce `colored` parameter to `RelatedObjectAttr`, `NestedObjectAttr`, and `ObjectListAttr` to render objects as colored badges when they expose a `color` attribute. Update badge template tag to support hex colors and optional URLs. Apply colored rendering to circuit types, device roles, rack roles, inventory item roles, and VM roles. Fixes #21430 --- netbox/circuits/tests/test_views.py | 14 ++++++++++ netbox/circuits/ui/panels.py | 4 +-- netbox/dcim/tests/test_views.py | 17 +++++++++++ netbox/dcim/ui/panels.py | 6 ++-- netbox/netbox/ui/attrs.py | 11 ++++++-- netbox/templates/ui/attrs/nested_object.html | 10 ++++++- netbox/templates/ui/attrs/object.html | 28 +++++++++++++++++-- netbox/templates/ui/attrs/object_list.html | 2 +- .../utilities/templates/builtins/badge.html | 6 +++- .../utilities/templatetags/builtins/tags.py | 8 ++++-- netbox/utilities/tests/test_templatetags.py | 17 ++++++++++- netbox/virtualization/ui/panels.py | 2 +- 12 files changed, 109 insertions(+), 16 deletions(-) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 6ced9a958..b3593b927 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -196,6 +196,20 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'comments': 'New comments', } + def test_circuit_type_display_colored(self): + circuit_type = CircuitType.objects.first() + circuit_type.color = '12ab34' + circuit_type.save() + + circuit = Circuit.objects.first() + + self.add_permissions('circuits.view_circuit') + response = self.client.get(circuit.get_absolute_url()) + + self.assertHttpStatus(response, 200) + self.assertContains(response, circuit_type.name) + self.assertContains(response, 'background-color: #12ab34') + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_bulk_import_objects_with_terminations(self): site = Site.objects.first() diff --git a/netbox/circuits/ui/panels.py b/netbox/circuits/ui/panels.py index e8d6cf9bb..d90ccf3d4 100644 --- a/netbox/circuits/ui/panels.py +++ b/netbox/circuits/ui/panels.py @@ -73,7 +73,7 @@ class CircuitPanel(panels.ObjectAttributesPanel): provider = attrs.RelatedObjectAttr('provider', linkify=True) provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True) cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True) - type = attrs.RelatedObjectAttr('type', linkify=True) + type = attrs.RelatedObjectAttr('type', linkify=True, colored=True) status = attrs.ChoiceAttr('status') distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display') tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') @@ -116,7 +116,7 @@ class VirtualCircuitPanel(panels.ObjectAttributesPanel): provider_network = attrs.RelatedObjectAttr('provider_network', linkify=True) provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True) cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True) - type = attrs.RelatedObjectAttr('type', linkify=True) + type = attrs.RelatedObjectAttr('type', linkify=True, colored=True) status = attrs.ChoiceAttr('status') tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') description = attrs.TextAttr('description') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 197af26b3..a67cea127 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2362,6 +2362,23 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.remove_permissions('dcim.view_device') self.assertHttpStatus(self.client.get(url), 403) + def test_device_role_display_colored(self): + parent_role = DeviceRole.objects.create(name='Parent Role', slug='parent-role', color='111111') + child_role = DeviceRole.objects.create(name='Child Role', slug='child-role', parent=parent_role, color='aa00bb') + + device = Device.objects.first() + device.role = child_role + device.save() + + self.add_permissions('dcim.view_device') + response = self.client.get(device.get_absolute_url()) + + self.assertHttpStatus(response, 200) + self.assertContains(response, 'Parent Role') + self.assertContains(response, 'Child Role') + self.assertContains(response, 'background-color: #aa00bb') + self.assertNotContains(response, 'background-color: #111111') + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_import_duplicate_ids_error_message(self): device = Device.objects.first() diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 07f9b8357..3681f4dac 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -50,7 +50,7 @@ class RackPanel(panels.ObjectAttributesPanel): tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status') rack_type = attrs.RelatedObjectAttr('rack_type', linkify=True, grouped_by='manufacturer') - role = attrs.RelatedObjectAttr('role', linkify=True) + role = attrs.RelatedObjectAttr('role', linkify=True, colored=True) description = attrs.TextAttr('description') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) @@ -103,7 +103,7 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel): title = _('Management') status = attrs.ChoiceAttr('status') - role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3) + role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3, colored=True) platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3) primary_ip4 = attrs.TemplatedAttr( 'primary_ip4', @@ -279,7 +279,7 @@ class InventoryItemPanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') label = attrs.TextAttr('label') status = attrs.ChoiceAttr('status') - role = attrs.RelatedObjectAttr('role', linkify=True) + role = attrs.RelatedObjectAttr('role', linkify=True, colored=True) component = attrs.GenericForeignKeyAttr('component', linkify=True) manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) part_id = attrs.TextAttr('part_id', label=_('Part ID')) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index e4bd93c4e..992ac6f2a 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -256,13 +256,15 @@ class RelatedObjectAttr(ObjectAttribute): linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view grouped_by (str): A second-order object to annotate alongside the related object; for example, an attribute representing the dcim.Site model might specify grouped_by="region" + colored (bool): If True, render the object as a colored badge when it exposes a `color` attribute """ template_name = 'ui/attrs/object.html' - def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): + def __init__(self, *args, linkify=None, grouped_by=None, colored=False, **kwargs): super().__init__(*args, **kwargs) self.linkify = linkify self.grouped_by = grouped_by + self.colored = colored def get_context(self, obj, context): value = self.get_value(obj) @@ -270,6 +272,7 @@ class RelatedObjectAttr(ObjectAttribute): return { 'linkify': self.linkify, 'group': group, + 'colored': self.colored, } @@ -327,6 +330,7 @@ class RelatedObjectListAttr(RelatedObjectAttr): return { 'linkify': self.linkify, + 'colored': self.colored, 'items': [ { 'value': item, @@ -358,13 +362,15 @@ class NestedObjectAttr(ObjectAttribute): Parameters: linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view max_depth (int): Maximum number of ancestors to display (default: all) + colored (bool): If True, render the object as a colored badge when it exposes a `color` attribute """ template_name = 'ui/attrs/nested_object.html' - def __init__(self, *args, linkify=None, max_depth=None, **kwargs): + def __init__(self, *args, linkify=None, max_depth=None, colored=False, **kwargs): super().__init__(*args, **kwargs) self.linkify = linkify self.max_depth = max_depth + self.colored = colored def get_context(self, obj, context): value = self.get_value(obj) @@ -374,6 +380,7 @@ class NestedObjectAttr(ObjectAttribute): return { 'nodes': nodes, 'linkify': self.linkify, + 'colored': self.colored, } diff --git a/netbox/templates/ui/attrs/nested_object.html b/netbox/templates/ui/attrs/nested_object.html index 8cae08189..5d7e52d2d 100644 --- a/netbox/templates/ui/attrs/nested_object.html +++ b/netbox/templates/ui/attrs/nested_object.html @@ -1,7 +1,15 @@ {% else %} {# Display only the object #} - {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %} + {% if colored and value.color %} + {% if linkify %} + {% with badge_url=value.get_absolute_url %} + {% badge value hex_color=value.color url=badge_url %} + {% endwith %} + {% else %} + {% badge value hex_color=value.color %} + {% endif %} + {% elif linkify %} + {{ value|linkify }} + {% else %} + {{ value }} + {% endif %} {% endif %} diff --git a/netbox/templates/ui/attrs/object_list.html b/netbox/templates/ui/attrs/object_list.html index 6daf3fcf2..58eaf2908 100644 --- a/netbox/templates/ui/attrs/object_list.html +++ b/netbox/templates/ui/attrs/object_list.html @@ -1,7 +1,7 @@