feat(ui): Add nested breadcrumb display for GenericForeignKey attrs

Add `nested` and `max_depth` params to GenericForeignKeyAttr to render
hierarchical objects as breadcrumbs when they expose `get_ancestors()`.
Applied to scope fields in IPAM/wireless and circuit termination points.

Fixes #21938
This commit is contained in:
Martin Hauser
2026-05-05 13:32:31 +02:00
parent 1e1548edd1
commit 3bfb47f279
9 changed files with 145 additions and 11 deletions

View File

@@ -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, max_depth=3, nested=True, label=_('Termination point')
)
connection = attrs.TemplatedAttr(
'pk',
template_name='circuits/circuit_termination/attrs/connection.html',

View File

@@ -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, max_depth=3, nested=True)
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, max_depth=3, nested=True)
vid_ranges = attrs.TemplatedAttr(
'vid_ranges_items',
template_name='ipam/vlangroup/attrs/vid_ranges.html',

View File

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

View File

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

View File

@@ -5,7 +5,11 @@
<th scope="row">{% trans "Termination point" %}</th>
{% if termination.termination %}
<td>
{{ 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 %}
<div class="fs-5 text-muted">{% trans termination.termination_type.name|bettertitle %}</div>
</td>
{% else %}

View File

@@ -24,7 +24,7 @@
</h2>
{% if termination %}
<table class="table table-hover attr-table">
{% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %}
{% include 'circuits/inc/circuit_termination_fields.html' with termination=termination termination_nodes=termination_nodes %}
<tr>
<th scope="row">{% trans "Tags" %}</th>
<td>

View File

@@ -1,5 +1,11 @@
{% load helpers %}
<div{% if name %} id="attr_{{ name }}"{% endif %}>
{% 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 %}<div class="fs-5 text-muted">{{ content_type|bettertitle }}</div>{% endif %}
</div>

View File

@@ -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, max_depth=3, nested=True)
#

View File

@@ -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, max_depth=3, nested=True)
description = attrs.TextAttr('description')
vlan = attrs.RelatedObjectAttr('vlan', label=_('VLAN'), linkify=True)
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')