Browse Source

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
Martin Hauser 1 day ago
parent
commit
7ff7c6d17e

+ 14 - 0
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()

+ 2 - 2
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')

+ 17 - 0
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()

+ 3 - 3
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'))

+ 9 - 2
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,
         }
 
 

+ 9 - 1
netbox/templates/ui/attrs/nested_object.html

@@ -1,7 +1,15 @@
 <ol class="breadcrumb" aria-label="breadcrumbs">
   {% for node in nodes %}
     <li class="breadcrumb-item">
-      {% if linkify %}
+      {% if forloop.last and colored and node.color %}
+        {% if linkify %}
+          {% with badge_url=node.get_absolute_url %}
+            {% badge node hex_color=node.color url=badge_url %}
+          {% endwith %}
+        {% else %}
+          {% badge node hex_color=node.color %}
+        {% endif %}
+      {% elif linkify %}
         <a href="{{ node.get_absolute_url }}">{{ node }}</a>
       {% else %}
         {{ node }}

+ 26 - 2
netbox/templates/ui/attrs/object.html

@@ -5,10 +5,34 @@
       {% if linkify %}{{ group|linkify }}{% else %}{{ group }}{% endif %}
     </li>
     <li class="breadcrumb-item">
-      {% 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 %}
     </li>
   </ol>
 {% 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 %}

+ 1 - 1
netbox/templates/ui/attrs/object_list.html

@@ -1,7 +1,7 @@
 <ul class="list-unstyled mb-0">
   {% for item in items %}
     <li>
-      {% include "ui/attrs/object.html" with value=item.value group=item.group linkify=linkify only %}
+      {% include "ui/attrs/object.html" with value=item.value group=item.group linkify=linkify colored=colored only %}
     </li>
   {% endfor %}
   {% if overflow_indicator %}

+ 5 - 1
netbox/utilities/templates/builtins/badge.html

@@ -1 +1,5 @@
-{% if value or show_empty %}<span class="badge text-bg-{{ bg_color }}">{{ value }}</span>{% endif %}
+{% load helpers %}
+
+{% if value or show_empty %}
+  {% if url %}<a href="{{ url }}">{% endif %}<span class="badge{% if not hex_color %} text-bg-{{ bg_color }}{% endif %}"{% if hex_color %} style="color: {{ hex_color|fgcolor }}; background-color: #{{ hex_color }}"{% endif %}>{{ value }}</span>{% if url %}</a>{% endif %}
+{% endif %}

+ 6 - 2
netbox/utilities/templatetags/builtins/tags.py

@@ -58,18 +58,22 @@ def customfield_value(customfield, value):
 
 
 @register.inclusion_tag('builtins/badge.html')
-def badge(value, bg_color=None, show_empty=False):
+def badge(value, bg_color=None, hex_color=None, url=None, show_empty=False):
     """
-    Display the specified number as a badge.
+    Display the specified value as a badge.
 
     Args:
         value: The value to be displayed within the badge
         bg_color: Background color CSS name
+        hex_color: Background color in hexadecimal RRGGBB format
+        url: If provided, wrap the badge in a hyperlink
         show_empty: If true, display the badge even if value is None or zero
     """
     return {
         'value': value,
         'bg_color': bg_color or 'secondary',
+        'hex_color': hex_color.lstrip('#') if hex_color else None,
+        'url': url,
         'show_empty': show_empty,
     }
 

+ 16 - 1
netbox/utilities/tests/test_templatetags.py

@@ -1,8 +1,9 @@
 from unittest.mock import patch
 
+from django.template.loader import render_to_string
 from django.test import TestCase, override_settings
 
-from utilities.templatetags.builtins.tags import static_with_params
+from utilities.templatetags.builtins.tags import badge, static_with_params
 from utilities.templatetags.helpers import _humanize_capacity, humanize_speed
 
 
@@ -49,6 +50,20 @@ class StaticWithParamsTest(TestCase):
                 self.assertNotIn('v=old_version', result)
 
 
+class BadgeTest(TestCase):
+    """
+    Test the badge template tag functionality.
+    """
+
+    def test_badge_with_hex_color_and_url(self):
+        html = render_to_string('builtins/badge.html', badge('Role', hex_color='ff0000', url='/dcim/device-roles/1/'))
+
+        self.assertIn('href="/dcim/device-roles/1/"', html)
+        self.assertIn('background-color: #ff0000', html)
+        self.assertIn('color: #ffffff', html)
+        self.assertIn('>Role<', html)
+
+
 class HumanizeCapacityTest(TestCase):
     """
     Test the _humanize_capacity function for correct SI/IEC unit label selection.

+ 1 - 1
netbox/virtualization/ui/panels.py

@@ -17,7 +17,7 @@ class VirtualMachinePanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     status = attrs.ChoiceAttr('status')
     start_on_boot = attrs.ChoiceAttr('start_on_boot')
-    role = attrs.RelatedObjectAttr('role', linkify=True)
+    role = attrs.RelatedObjectAttr('role', linkify=True, colored=True)
     platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3)
     description = attrs.TextAttr('description')
     serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)