mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-11 11:47:08 +02:00
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:
committed by
Jeremy Stretch
parent
296e708e09
commit
7ff7c6d17e
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user