mirror of
https://github.com/netbox-community/netbox.git
synced 2026-05-06 14:04:12 +08:00
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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user