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
This commit is contained in:
Martin Hauser
2026-04-07 18:52:36 +02:00
committed by Jeremy Stretch
parent 296e708e09
commit 7ff7c6d17e
12 changed files with 109 additions and 16 deletions

View File

@@ -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()

View File

@@ -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')

View File

@@ -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()

View File

@@ -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'))

View File

@@ -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,
}

View File

@@ -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 }}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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,
}

View File

@@ -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.

View File

@@ -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)