Parcourir la source

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
Martin Hauser il y a 2 semaines
Parent
commit
770c3647fb

+ 31 - 2
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',

+ 2 - 2
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',

+ 66 - 0
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):
 

+ 31 - 2
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,
         }
 
 

+ 5 - 1
netbox/templates/circuits/inc/circuit_termination_fields.html

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

+ 1 - 1
netbox/templates/circuits/panels/circuit_circuit_termination.html

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

+ 7 - 1
netbox/templates/ui/attrs/generic_object.html

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

+ 1 - 1
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)
 
 
 #

+ 1 - 1
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')