diff --git a/netbox/circuits/ui/panels.py b/netbox/circuits/ui/panels.py index 3572479cd..a874da3da 100644 --- a/netbox/circuits/ui/panels.py +++ b/netbox/circuits/ui/panels.py @@ -12,16 +12,43 @@ class CircuitCircuitTerminationPanel(panels.ObjectPanel): template_name = 'circuits/panels/circuit_circuit_termination.html' title = _('Termination') + termination_ancestor_max_depth = 3 def __init__(self, side, accessor=None, **kwargs): super().__init__(accessor=accessor, **kwargs) self.side = side + def _get_termination_nodes(self, termination): + """ + Return the termination target's ancestors, including itself, when the + target is tree-like. + + Non-tree GFK targets return None, so the template preserves the current + single-object rendering. + """ + target = getattr(termination, 'termination', None) + if target is None: + return None + + get_ancestors = getattr(target, 'get_ancestors', None) + if not callable(get_ancestors): + return None + + nodes = list(get_ancestors(include_self=True)) + + if self.termination_ancestor_max_depth is not None: + nodes = nodes[-self.termination_ancestor_max_depth:] + + return nodes + def get_context(self, context): + termination = resolve_attr_path(context, f'{self.accessor}.termination_{self.side.lower()}') + return { **super().get_context(context), 'side': self.side, - 'termination': resolve_attr_path(context, f'{self.accessor}.termination_{self.side.lower()}'), + 'termination': termination, + 'termination_nodes': self._get_termination_nodes(termination), } @@ -58,7 +85,9 @@ class CircuitTerminationPanel(panels.ObjectAttributesPanel): title = _('Circuit Termination') circuit = attrs.RelatedObjectAttr('circuit', linkify=True) provider = attrs.RelatedObjectAttr('circuit.provider', linkify=True) - termination = attrs.GenericForeignKeyAttr('termination', linkify=True, label=_('Termination point')) + termination = attrs.GenericForeignKeyAttr( + 'termination', linkify=True, nested=True, max_depth=3, label=_('Termination point') + ) connection = attrs.TemplatedAttr( 'pk', template_name='circuits/circuit_termination/attrs/connection.html', diff --git a/netbox/ipam/ui/panels.py b/netbox/ipam/ui/panels.py index c8984352a..6e0f53d34 100644 --- a/netbox/ipam/ui/panels.py +++ b/netbox/ipam/ui/panels.py @@ -144,7 +144,7 @@ class PrefixPanel(panels.ObjectAttributesPanel): template_name='ipam/prefix/attrs/aggregate.html', label=_('Aggregate'), ) - scope = attrs.GenericForeignKeyAttr('scope', linkify=True) + scope = attrs.GenericForeignKeyAttr('scope', linkify=True, nested=True, max_depth=3) vlan = attrs.RelatedObjectAttr('vlan', linkify=True, label=_('VLAN'), grouped_by='group') status = attrs.ChoiceAttr('status') role = attrs.RelatedObjectAttr('role', linkify=True) @@ -155,7 +155,7 @@ class PrefixPanel(panels.ObjectAttributesPanel): class VLANGroupPanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') description = attrs.TextAttr('description') - scope = attrs.GenericForeignKeyAttr('scope', linkify=True) + scope = attrs.GenericForeignKeyAttr('scope', linkify=True, nested=True, max_depth=3) vid_ranges = attrs.TemplatedAttr( 'vid_ranges_items', template_name='ipam/vlangroup/attrs/vid_ranges.html', diff --git a/netbox/netbox/tests/test_ui.py b/netbox/netbox/tests/test_ui.py index 31c38bf81..a1cd6fab6 100644 --- a/netbox/netbox/tests/test_ui.py +++ b/netbox/netbox/tests/test_ui.py @@ -341,6 +341,23 @@ class RelatedObjectAttrTest(TestCase): class GenericForeignKeyAttrTest(TestCase): + class TreeNode: + def __init__(self, name, ancestors=()): + self.name = name + self.ancestors = list(ancestors) + self.include_self = None + self._meta = SimpleNamespace(verbose_name='location') + + def __str__(self): + return self.name + + def get_ancestors(self, include_self=False): + self.include_self = include_self + + if include_self: + return [*self.ancestors, self] + return self.ancestors + def test_get_context_content_type(self): value = SimpleNamespace(_meta=SimpleNamespace(verbose_name='provider')) obj = SimpleNamespace() @@ -355,6 +372,55 @@ class GenericForeignKeyAttrTest(TestCase): context = attr.get_context(obj, 'assigned_object', value, {}) self.assertTrue(context['linkify']) + def test_get_context_nested_disabled(self): + root = self.TreeNode('Root') + child = self.TreeNode('Child', ancestors=[root]) + + obj = SimpleNamespace() + attr = attrs.GenericForeignKeyAttr('assigned_object') + context = attr.get_context(obj, 'assigned_object', child, {}) + + self.assertIsNone(context['nodes']) + + def test_get_context_nested_non_hierarchical_object(self): + value = SimpleNamespace(_meta=SimpleNamespace(verbose_name='site')) + obj = SimpleNamespace() + attr = attrs.GenericForeignKeyAttr('assigned_object', nested=True) + context = attr.get_context(obj, 'assigned_object', value, {}) + + self.assertIsNone(context['nodes']) + + def test_get_context_nested_hierarchical_object(self): + root = self.TreeNode('Root') + parent = self.TreeNode('Parent', ancestors=[root]) + child = self.TreeNode('Child', ancestors=[root, parent]) + + obj = SimpleNamespace() + attr = attrs.GenericForeignKeyAttr('assigned_object', nested=True) + context = attr.get_context(obj, 'assigned_object', child, {}) + + self.assertEqual(context['nodes'], [root, parent, child]) + self.assertTrue(child.include_self) + + def test_get_context_nested_max_depth(self): + root = self.TreeNode('Root') + parent = self.TreeNode('Parent', ancestors=[root]) + child = self.TreeNode('Child', ancestors=[root, parent]) + + obj = SimpleNamespace() + attr = attrs.GenericForeignKeyAttr('assigned_object', nested=True, max_depth=2) + context = attr.get_context(obj, 'assigned_object', child, {}) + + self.assertEqual(context['nodes'], [parent, child]) + + def test_get_context_nested_null_value(self): + obj = SimpleNamespace() + attr = attrs.GenericForeignKeyAttr('assigned_object', nested=True) + context = attr.get_context(obj, 'assigned_object', None, {}) + + self.assertIsNone(context['content_type']) + self.assertIsNone(context['nodes']) + class GPSCoordinatesAttrTest(TestCase): diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index d5f066ddd..e2535a40c 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -412,19 +412,48 @@ class GenericForeignKeyAttr(ObjectAttribute): Parameters: linkify (bool): If True, the rendered value will be hyperlinked - to the related object's detail view + to the related object's detail view. + nested (bool): If True and the related object exposes a callable + `get_ancestors(include_self=True)`, render the object together + with its ancestors as a breadcrumb, similar to `NestedObjectAttr`. + Non-hierarchical objects continue to render normally. + max_depth (int): Maximum number of ancestors to display when + `nested` is enabled. Ignored otherwise. """ template_name = 'ui/attrs/generic_object.html' - def __init__(self, *args, linkify=None, **kwargs): + def __init__(self, *args, linkify=None, nested=False, max_depth=None, **kwargs): super().__init__(*args, **kwargs) self.linkify = linkify + self.nested = nested + self.max_depth = max_depth + + def _get_nodes(self, value): + """ + Retrieves a list of nodes representing the hierarchical path to a given value. + """ + if value is None: + return None + + get_ancestors = getattr(value, 'get_ancestors', None) + if not callable(get_ancestors): + return None + + nodes = list(get_ancestors(include_self=True)) + + if self.max_depth is not None: + nodes = nodes[-self.max_depth:] + + return nodes def get_context(self, obj, attr, value, context): content_type = value._meta.verbose_name if value is not None else None + nodes = self._get_nodes(value) if (self.nested and value is not None) else None + return { 'content_type': content_type, 'linkify': self.linkify, + 'nodes': nodes, } diff --git a/netbox/templates/circuits/inc/circuit_termination_fields.html b/netbox/templates/circuits/inc/circuit_termination_fields.html index 1ea465fca..cc37ed0d2 100644 --- a/netbox/templates/circuits/inc/circuit_termination_fields.html +++ b/netbox/templates/circuits/inc/circuit_termination_fields.html @@ -5,7 +5,11 @@ {% trans "Termination point" %} {% if termination.termination %} - {{ termination.termination|linkify }} + {% if termination_nodes %} + {% include 'ui/attrs/nested_object.html' with nodes=termination_nodes linkify=True colored=False only %} + {% else %} + {{ termination.termination|linkify }} + {% endif %}
{% trans termination.termination_type.name|bettertitle %}
{% else %} diff --git a/netbox/templates/circuits/panels/circuit_circuit_termination.html b/netbox/templates/circuits/panels/circuit_circuit_termination.html index 5f79d48d4..589d2bd2b 100644 --- a/netbox/templates/circuits/panels/circuit_circuit_termination.html +++ b/netbox/templates/circuits/panels/circuit_circuit_termination.html @@ -24,7 +24,7 @@ {% if termination %} - {% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %} + {% include 'circuits/inc/circuit_termination_fields.html' with termination=termination termination_nodes=termination_nodes %}
{% trans "Tags" %} diff --git a/netbox/templates/ui/attrs/generic_object.html b/netbox/templates/ui/attrs/generic_object.html index 64b1a6d9a..9a7a2d498 100644 --- a/netbox/templates/ui/attrs/generic_object.html +++ b/netbox/templates/ui/attrs/generic_object.html @@ -1,5 +1,11 @@ {% load helpers %} - {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %} + {% if nodes %} + {% include 'ui/attrs/nested_object.html' with nodes=nodes linkify=linkify colored=False only %} + {% elif linkify %} + {{ value|linkify }} + {% else %} + {{ value }} + {% endif %} {% if content_type %}
{{ content_type|bettertitle }}
{% endif %} diff --git a/netbox/virtualization/ui/panels.py b/netbox/virtualization/ui/panels.py index f5505e392..f562efc77 100644 --- a/netbox/virtualization/ui/panels.py +++ b/netbox/virtualization/ui/panels.py @@ -14,7 +14,7 @@ class ClusterPanel(panels.ObjectAttributesPanel): description = attrs.TextAttr('description') group = attrs.RelatedObjectAttr('group', linkify=True) tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') - scope = attrs.GenericForeignKeyAttr('scope', linkify=True) + scope = attrs.GenericForeignKeyAttr('scope', linkify=True, nested=True, max_depth=3) # diff --git a/netbox/wireless/ui/panels.py b/netbox/wireless/ui/panels.py index 0135fe4bb..df440724a 100644 --- a/netbox/wireless/ui/panels.py +++ b/netbox/wireless/ui/panels.py @@ -11,7 +11,7 @@ class WirelessLANPanel(panels.ObjectAttributesPanel): ssid = attrs.TextAttr('ssid', label=_('SSID')) group = attrs.RelatedObjectAttr('group', linkify=True) status = attrs.ChoiceAttr('status') - scope = attrs.GenericForeignKeyAttr('scope', linkify=True) + scope = attrs.GenericForeignKeyAttr('scope', linkify=True, nested=True, max_depth=3) description = attrs.TextAttr('description') vlan = attrs.RelatedObjectAttr('vlan', label=_('VLAN'), linkify=True) tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')