Browse Source

Closes #22446: Add breadcrumbs support for Layouts (#22546)

Jeremy Stretch 3 ngày trước cách đây
mục cha
commit
48ce5e7e2c
70 tập tin đã thay đổi với 772 bổ sung420 xóa
  1. 67 0
      docs/plugins/development/ui-components.md
  2. 9 2
      netbox/account/views.py
  3. 44 0
      netbox/circuits/views.py
  4. 6 0
      netbox/core/views.py
  5. 98 0
      netbox/dcim/views.py
  6. 32 0
      netbox/ipam/views.py
  7. 250 2
      netbox/netbox/tests/test_ui.py
  8. 152 0
      netbox/netbox/ui/breadcrumbs.py
  9. 19 3
      netbox/netbox/ui/layout.py
  10. 0 8
      netbox/templates/account/token.html
  11. 0 6
      netbox/templates/circuits/circuit.html
  12. 0 5
      netbox/templates/circuits/circuitgroup.html
  13. 0 8
      netbox/templates/circuits/circuitgroupassignment.html
  14. 0 9
      netbox/templates/circuits/circuittermination.html
  15. 0 6
      netbox/templates/circuits/provideraccount.html
  16. 0 6
      netbox/templates/circuits/providernetwork.html
  17. 0 11
      netbox/templates/circuits/virtualcircuit.html
  18. 0 15
      netbox/templates/circuits/virtualcircuittermination.html
  19. 0 3
      netbox/templates/core/configrevision.html
  20. 0 7
      netbox/templates/core/datafile.html
  21. 0 4
      netbox/templates/dcim/cablebundle.html
  22. 0 9
      netbox/templates/dcim/consoleport.html
  23. 0 9
      netbox/templates/dcim/consoleserverport.html
  24. 0 9
      netbox/templates/dcim/devicebay.html
  25. 0 7
      netbox/templates/dcim/devicerole.html
  26. 0 5
      netbox/templates/dcim/devicetype/base.html
  27. 0 9
      netbox/templates/dcim/frontport.html
  28. 0 7
      netbox/templates/dcim/interface.html
  29. 0 9
      netbox/templates/dcim/inventoryitem.html
  30. 0 8
      netbox/templates/dcim/location.html
  31. 0 7
      netbox/templates/dcim/module.html
  32. 0 9
      netbox/templates/dcim/modulebay.html
  33. 0 7
      netbox/templates/dcim/moduletype.html
  34. 0 7
      netbox/templates/dcim/platform.html
  35. 0 10
      netbox/templates/dcim/powerfeed.html
  36. 0 9
      netbox/templates/dcim/poweroutlet.html
  37. 0 9
      netbox/templates/dcim/powerpanel.html
  38. 0 9
      netbox/templates/dcim/powerport.html
  39. 0 11
      netbox/templates/dcim/rack/base.html
  40. 0 8
      netbox/templates/dcim/rackreservation.html
  41. 0 9
      netbox/templates/dcim/rearport.html
  42. 0 7
      netbox/templates/dcim/region.html
  43. 0 7
      netbox/templates/dcim/sitegroup.html
  44. 1 5
      netbox/templates/generic/object.html
  45. 0 1
      netbox/templates/ipam/aggregate.html
  46. 0 8
      netbox/templates/ipam/aggregate/base.html
  47. 0 10
      netbox/templates/ipam/asn.html
  48. 0 1
      netbox/templates/ipam/asnrange.html
  49. 0 6
      netbox/templates/ipam/asnrange/base.html
  50. 0 1
      netbox/templates/ipam/ipaddress.html
  51. 0 8
      netbox/templates/ipam/ipaddress/base.html
  52. 0 1
      netbox/templates/ipam/iprange.html
  53. 0 10
      netbox/templates/ipam/iprange/base.html
  54. 0 7
      netbox/templates/ipam/prefix/base.html
  55. 0 1
      netbox/templates/ipam/vlan.html
  56. 0 16
      netbox/templates/ipam/vlan/base.html
  57. 0 8
      netbox/templates/ipam/vlangroup.html
  58. 0 8
      netbox/templates/tenancy/contactgroup.html
  59. 0 11
      netbox/templates/tenancy/tenant.html
  60. 0 7
      netbox/templates/tenancy/tenantgroup.html
  61. 3 0
      netbox/templates/ui/breadcrumb.html
  62. 0 8
      netbox/templates/virtualization/cluster/base.html
  63. 0 12
      netbox/templates/virtualization/virtualdisk.html
  64. 0 8
      netbox/templates/virtualization/vminterface.html
  65. 0 7
      netbox/templates/wireless/wirelesslangroup.html
  66. 21 0
      netbox/tenancy/views.py
  67. 28 0
      netbox/utilities/templatetags/builtins/tags.py
  68. 16 0
      netbox/utilities/views.py
  69. 19 0
      netbox/virtualization/views.py
  70. 7 0
      netbox/wireless/views.py

+ 67 - 0
docs/plugins/development/ui-components.md

@@ -53,6 +53,73 @@ class MyView(generic.ObjectView):
 
 ::: netbox.ui.layout.Column
 
+## Breadcrumbs
+
+Breadcrumbs are rendered at the top of an object's page to convey its position within a hierarchy and to provide quick navigation to related objects. By default, a single breadcrumb linking to the object's list view is shown. To add object-specific breadcrumbs, pass a list of `Breadcrumb` instances to your layout, just as you would its panels.
+
+A `Breadcrumb` typically references an _accessor_ (rather than a static value), which is resolved against the object being viewed when the page is rendered. The accessor may be a dotted attribute path or a callable. (A breadcrumb may instead define a static `label`; see below.)
+
+```python
+from netbox.ui import layout
+from netbox.ui.breadcrumbs import Breadcrumb
+from netbox.views import generic
+
+class MyView(generic.ObjectView):
+    layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('site'),
+            Breadcrumb('location'),
+            Breadcrumb('rack'),
+        ],
+        left_panels=[...],
+        right_panels=[...],
+    )
+```
+
+Each breadcrumb renders as a label (the string representation of the resolved object) and an optional link. If no explicit `url` is provided, the object's `get_absolute_url()` is used when available. A breadcrumb whose accessor resolves to `None` (or an empty iterable) renders as an empty string and is omitted, which simplifies conditional breadcrumbs (e.g. where a device may or may not be assigned to a rack).
+
+To link a breadcrumb somewhere other than the related object's own page (for example, to a filtered list view), pass a `url`. A callable `url` receives the resolved object:
+
+```python
+from django.urls import reverse
+
+Breadcrumb('rir', url=lambda rir: f"{reverse('ipam:asn_list')}?rir_id={rir.pk}")
+```
+
+A callable accessor which returns an iterable renders one breadcrumb per object, which is useful for representing a hierarchy of ancestors:
+
+```python
+Breadcrumb(lambda obj: obj.get_ancestors())
+```
+
+To render a breadcrumb that isn't tied to a related object, omit the accessor and pass a `label`. This is useful for linking to a parent view that isn't reachable via an attribute on the object (e.g. a user's personal token list):
+
+```python
+from django.urls import reverse_lazy
+
+Breadcrumb(label=_('My API Tokens'), url=reverse_lazy('account:usertoken_list'))
+```
+
+The `label` may also be a callable, which receives the relevant object (the resolved related object when an accessor is given, otherwise the viewed instance). This is useful for an unlinked descriptive crumb derived from the object:
+
+```python
+Breadcrumb(label=lambda obj: f"{_('Units')} {obj.unit_list}")
+```
+
+The default root breadcrumb (linking to the object's list view) is prepended to the trail automatically. Where that list view isn't an appropriate root—for example, the global token list is admin-only, so a user's personal token page links to their own token list instead—pass `root_breadcrumb=False` to the layout and supply a replacement as the first breadcrumb:
+
+```python
+SimpleLayout(
+    root_breadcrumb=False,
+    breadcrumbs=[
+        Breadcrumb(label=_('My API Tokens'), url=reverse_lazy('account:usertoken_list')),
+    ],
+    ...
+)
+```
+
+::: netbox.ui.breadcrumbs.Breadcrumb
+
 ## Panels
 
 Within each column, related blocks of content are arranged into panels. Each panel has a title and may have a set of associated actions, but the content within is otherwise arbitrary.

+ 9 - 2
netbox/account/views.py

@@ -11,7 +11,7 @@ from django.contrib.auth.models import update_last_login
 from django.contrib.auth.signals import user_logged_in
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render, resolve_url
-from django.urls import reverse
+from django.urls import reverse, reverse_lazy
 from django.utils.decorators import method_decorator
 from django.utils.http import urlencode
 from django.utils.translation import gettext_lazy as _
@@ -27,6 +27,7 @@ from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
 from netbox.authentication import get_auth_backend_display, get_saml_idps
 from netbox.config import get_config
 from netbox.ui import layout
+from netbox.ui.breadcrumbs import Breadcrumb
 from netbox.views import generic
 from users import forms
 from users.models import UserConfig
@@ -345,6 +346,12 @@ class UserTokenListView(LoginRequiredMixin, View):
 @register_model_view(UserToken)
 class UserTokenView(LoginRequiredMixin, View):
     layout = layout.SimpleLayout(
+        # The global UserToken list view is admin-only, so substitute the user's personal token list
+        # for the default root breadcrumb.
+        root_breadcrumb=False,
+        breadcrumbs=[
+            Breadcrumb(label=_('My API Tokens'), url=reverse_lazy('account:usertoken_list')),
+        ],
         left_panels=[
             TokenPanel(),
         ],
@@ -362,7 +369,7 @@ class UserTokenView(LoginRequiredMixin, View):
         plaintext = request.session.pop(f'_token_plaintext_{token.pk}', None)
         token_auth_string = f'{token.get_auth_header_prefix()}{plaintext}' if plaintext else None
 
-        return render(request, 'account/token.html', {
+        return render(request, 'users/token.html', {
             'object': token,
             'layout': self.layout,
             'token_auth_string': token_auth_string,

+ 44 - 0
netbox/circuits/views.py

@@ -5,6 +5,7 @@ from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from ipam.models import ASN
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.ui import actions, layout
+from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url
 from netbox.ui.panels import (
     CommentsPanel,
     ObjectsTablePanel,
@@ -147,8 +148,12 @@ class ProviderAccountListView(generic.ObjectListView):
 
 @register_model_view(ProviderAccount)
 class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ProviderAccount.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('provider', url=filtered_list_url('circuits:provideraccount_list', 'provider_id')),
+        ],
         left_panels=[
             panels.ProviderAccountPanel(),
             TagsPanel(),
@@ -240,8 +245,12 @@ class ProviderNetworkListView(generic.ObjectListView):
 
 @register_model_view(ProviderNetwork)
 class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ProviderNetwork.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('provider', url=filtered_list_url('circuits:providernetwork_list', 'provider_id')),
+        ],
         left_panels=[
             panels.ProviderNetworkPanel(),
             TagsPanel(),
@@ -422,8 +431,12 @@ class CircuitListView(generic.ObjectListView):
 
 @register_model_view(Circuit)
 class CircuitView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = Circuit.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('provider', url=filtered_list_url('circuits:circuit_list', 'provider_id')),
+        ],
         left_panels=[
             panels.CircuitPanel(),
             panels.CircuitGroupAssignmentsPanel(),
@@ -508,8 +521,12 @@ class CircuitTerminationListView(generic.ObjectListView):
 
 @register_model_view(CircuitTermination)
 class CircuitTerminationView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = CircuitTermination.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('circuit.provider', url=filtered_list_url('circuits:circuit_list', 'provider_id')),
+        ],
         left_panels=[
             panels.CircuitTerminationPanel(),
         ],
@@ -646,8 +663,12 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
 
 @register_model_view(CircuitGroupAssignment)
 class CircuitGroupAssignmentView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = CircuitGroupAssignment.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('group', url=filtered_list_url('circuits:circuitgroupassignment_list', 'group_id')),
+        ],
         left_panels=[
             panels.CircuitGroupAssignmentPanel(),
             TagsPanel(),
@@ -785,8 +806,16 @@ class VirtualCircuitListView(generic.ObjectListView):
 
 @register_model_view(VirtualCircuit)
 class VirtualCircuitView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = VirtualCircuit.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('provider', url=filtered_list_url('circuits:virtualcircuit_list', 'provider_id')),
+            Breadcrumb(
+                'provider_network',
+                url=filtered_list_url('circuits:virtualcircuit_list', 'provider_network_id'),
+            ),
+        ],
         left_panels=[
             panels.VirtualCircuitPanel(),
             TagsPanel(),
@@ -881,8 +910,23 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
 
 @register_model_view(VirtualCircuitTermination)
 class VirtualCircuitTerminationView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = VirtualCircuitTermination.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                'virtual_circuit.provider',
+                url=filtered_list_url('circuits:virtualcircuit_list', 'provider_id'),
+            ),
+            Breadcrumb(
+                'virtual_circuit.provider_network',
+                url=filtered_list_url('circuits:virtualcircuit_list', 'provider_network_id'),
+            ),
+            Breadcrumb(
+                'virtual_circuit',
+                url=filtered_list_url('circuits:virtualcircuittermination_list', 'virtual_circuit_id'),
+            ),
+        ],
         left_panels=[
             panels.VirtualCircuitTerminationPanel(),
             TagsPanel(),

+ 6 - 0
netbox/core/views.py

@@ -37,6 +37,7 @@ from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObjec
 from netbox.plugins import PluginConfig
 from netbox.plugins.utils import get_installed_plugins
 from netbox.ui import layout
+from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url
 from netbox.ui.panels import (
     CommentsPanel,
     JSONPanel,
@@ -195,6 +196,7 @@ class DataFileListView(generic.ObjectListView):
 
 @register_model_view(DataFile)
 class DataFileView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = DataFile.objects.all()
     actions = (DeleteObject,)
     layout = layout.Layout(
@@ -210,6 +212,9 @@ class DataFileView(generic.ObjectView):
                 PluginContentPanel('full_width_page'),
             ),
         ),
+        breadcrumbs=[
+            Breadcrumb('source', url=filtered_list_url('core:datafile_list', 'source_id')),
+        ],
     )
 
 
@@ -434,6 +439,7 @@ class ConfigRevisionView(generic.ObjectView):
                 PluginContentPanel('full_width_page'),
             ),
         ),
+        root_breadcrumb=False,
     )
 
     def get_extra_context(self, request, instance):

+ 98 - 0
netbox/dcim/views.py

@@ -20,6 +20,7 @@ from ipam.tables import VLANTranslationRuleTable
 from ipam.ui.panels import FHRPGroupAssignmentsPanel
 from netbox.object_actions import *
 from netbox.ui import actions, layout
+from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url, object_view_url
 from netbox.ui.panels import (
     CommentsPanel,
     ContextTablePanel,
@@ -243,6 +244,12 @@ class RegionListView(generic.ObjectListView):
 class RegionView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Region.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                lambda o: o.get_ancestors(),
+                url=filtered_list_url('dcim:region_list', 'parent_id'),
+            ),
+        ],
         left_panels=[
             NestedGroupObjectPanel(),
             TagsPanel(),
@@ -376,6 +383,12 @@ class SiteGroupListView(generic.ObjectListView):
 class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = SiteGroup.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                lambda o: o.get_ancestors(),
+                url=filtered_list_url('dcim:sitegroup_list', 'parent_id'),
+            ),
+        ],
         left_panels=[
             NestedGroupObjectPanel(),
             TagsPanel(),
@@ -660,8 +673,12 @@ class LocationListView(generic.ObjectListView):
 
 @register_model_view(Location)
 class LocationView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = Location.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(lambda o: o.get_ancestors()),
+        ],
         left_panels=[
             panels.LocationPanel(),
             TagsPanel(),
@@ -1098,6 +1115,14 @@ class RackElevationListView(generic.ObjectListView):
 class RackView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('site', url=filtered_list_url('dcim:rack_list', 'site_id')),
+            Breadcrumb(
+                lambda o: o.location.get_ancestors() if o.location else [],
+                url=filtered_list_url('dcim:rack_list', 'location_id'),
+            ),
+            Breadcrumb('location', url=filtered_list_url('dcim:rack_list', 'location_id')),
+        ],
         left_panels=[
             panels.RackPanel(),
             panels.RackDimensionsPanel(title=_('Dimensions')),
@@ -1245,10 +1270,15 @@ class RackReservationListView(generic.ObjectListView):
 
 @register_model_view(RackReservation)
 class RackReservationView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = RackReservation.objects.annotate(
         unit_count=Func('units', function='CARDINALITY', output_field=IntegerField())
     )
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('rack', url=filtered_list_url('dcim:rackreservation_list', 'rack_id')),
+            Breadcrumb(label=lambda o: f"{_('Units')} {o.unit_list}"),
+        ],
         left_panels=[
             panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'group', 'name']),
             panels.RackReservationPanel(title=_('Reservation')),
@@ -1414,6 +1444,9 @@ class DeviceTypeListView(generic.ObjectListView):
 class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = DeviceType.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('manufacturer', url=filtered_list_url('dcim:devicetype_list', 'manufacturer_id')),
+        ],
         left_panels=[
             panels.DeviceTypePanel(),
             TagsPanel(),
@@ -1764,6 +1797,9 @@ class ModuleTypeListView(generic.ObjectListView):
 class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ModuleType.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('manufacturer', url=filtered_list_url('dcim:moduletype_list', 'manufacturer_id')),
+        ],
         left_panels=[
             panels.ModuleTypePanel(),
             TagsPanel(),
@@ -2429,6 +2465,12 @@ class DeviceRoleListView(generic.ObjectListView):
 class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = DeviceRole.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                lambda o: o.get_ancestors(),
+                url=filtered_list_url('dcim:devicerole_list', 'parent_id'),
+            ),
+        ],
         left_panels=[
             panels.DeviceRolePanel(),
             TagsPanel(),
@@ -2530,6 +2572,9 @@ class PlatformListView(generic.ObjectListView):
 class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Platform.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('manufacturer', url=filtered_list_url('dcim:platform_list', 'manufacturer_id')),
+        ],
         left_panels=[
             panels.PlatformPanel(),
             TagsPanel(),
@@ -2956,6 +3001,9 @@ class ModuleListView(generic.ObjectListView):
 class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Module.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('module_type', url=filtered_list_url('dcim:module_list', 'module_type_id')),
+        ],
         left_panels=[
             panels.ModulePanel(),
             TagsPanel(),
@@ -3024,8 +3072,12 @@ class ConsolePortListView(generic.ObjectListView):
 
 @register_model_view(ConsolePort)
 class ConsolePortView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ConsolePort.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_consoleports')),
+        ],
         left_panels=[
             panels.ConsolePortPanel(),
             CustomFieldsPanel(),
@@ -3118,8 +3170,12 @@ class ConsoleServerPortListView(generic.ObjectListView):
 
 @register_model_view(ConsoleServerPort)
 class ConsoleServerPortView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ConsoleServerPort.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_consoleserverports')),
+        ],
         left_panels=[
             panels.ConsoleServerPortPanel(),
             CustomFieldsPanel(),
@@ -3208,8 +3264,12 @@ class PowerPortListView(generic.ObjectListView):
 
 @register_model_view(PowerPort)
 class PowerPortView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = PowerPort.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_powerports')),
+        ],
         left_panels=[
             panels.PowerPortPanel(),
             CustomFieldsPanel(),
@@ -3297,8 +3357,12 @@ class PowerOutletListView(generic.ObjectListView):
 
 @register_model_view(PowerOutlet)
 class PowerOutletView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = PowerOutlet.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_poweroutlets')),
+        ],
         left_panels=[
             panels.PowerOutletPanel(),
             CustomFieldsPanel(),
@@ -3387,6 +3451,9 @@ class InterfaceListView(generic.ObjectListView):
 class InterfaceView(generic.ObjectView):
     queryset = Interface.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_interfaces')),
+        ],
         left_panels=[
             panels.InterfacePanel(),
             panels.RelatedInterfacesPanel(),
@@ -3563,8 +3630,12 @@ class FrontPortListView(generic.ObjectListView):
 
 @register_model_view(FrontPort)
 class FrontPortView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = FrontPort.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_frontports')),
+        ],
         left_panels=[
             panels.FrontPortPanel(),
             CustomFieldsPanel(),
@@ -3667,8 +3738,12 @@ class RearPortListView(generic.ObjectListView):
 
 @register_model_view(RearPort)
 class RearPortView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = RearPort.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_rearports')),
+        ],
         left_panels=[
             panels.RearPortPanel(),
             CustomFieldsPanel(),
@@ -3769,8 +3844,12 @@ class ModuleBayListView(generic.ObjectListView):
 
 @register_model_view(ModuleBay)
 class ModuleBayView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ModuleBay.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_modulebays')),
+        ],
         left_panels=[
             panels.ModuleBayPanel(),
             TagsPanel(),
@@ -3842,8 +3921,12 @@ class DeviceBayListView(generic.ObjectListView):
 
 @register_model_view(DeviceBay)
 class DeviceBayView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = DeviceBay.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_devicebays')),
+        ],
         left_panels=[
             panels.DeviceBayPanel(),
             CustomFieldsPanel(),
@@ -3996,8 +4079,12 @@ class InventoryItemListView(generic.ObjectListView):
 
 @register_model_view(InventoryItem)
 class InventoryItemView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = InventoryItem.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_inventory')),
+        ],
         left_panels=[
             panels.InventoryItemPanel(),
             CustomFieldsPanel(),
@@ -4744,8 +4831,13 @@ class PowerPanelListView(generic.ObjectListView):
 
 @register_model_view(PowerPanel)
 class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = PowerPanel.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('site', url=filtered_list_url('dcim:powerpanel_list', 'site_id')),
+            Breadcrumb('location'),
+        ],
         left_panels=[
             panels.PowerPanelPanel(),
             TagsPanel(),
@@ -4828,8 +4920,14 @@ class PowerFeedListView(generic.ObjectListView):
 
 @register_model_view(PowerFeed)
 class PowerFeedView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = PowerFeed.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('power_panel.site', url=filtered_list_url('dcim:powerfeed_list', 'site_id')),
+            Breadcrumb('power_panel', url=filtered_list_url('dcim:powerfeed_list', 'power_panel_id')),
+            Breadcrumb('rack', url=filtered_list_url('dcim:powerfeed_list', 'rack_id')),
+        ],
         left_panels=[
             panels.PowerFeedPanel(),
             panels.PowerFeedElectricalPanel(),

+ 32 - 0
netbox/ipam/views.py

@@ -12,6 +12,7 @@ from dcim.models import Device, Interface, Site
 from extras.ui.panels import CustomFieldsPanel, TagsPanel
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.ui import actions, layout
+from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url
 from netbox.ui.panels import (
     CommentsPanel,
     ContextTablePanel,
@@ -358,8 +359,12 @@ class ASNRangeListView(generic.ObjectListView):
 
 @register_model_view(ASNRange)
 class ASNRangeView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ASNRange.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('rir', url=filtered_list_url('ipam:asnrange_list', 'rir_id')),
+        ],
         left_panels=[
             panels.ASNRangePanel(),
             TagsPanel(),
@@ -448,8 +453,12 @@ class ASNListView(generic.ObjectListView):
 
 @register_model_view(ASN)
 class ASNView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ASN.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('rir', url=filtered_list_url('ipam:asn_list', 'rir_id')),
+        ],
         left_panels=[
             panels.ASNPanel(),
             TagsPanel(),
@@ -534,8 +543,12 @@ class AggregateListView(generic.ObjectListView):
 
 @register_model_view(Aggregate)
 class AggregateView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = Aggregate.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('rir', url=filtered_list_url('ipam:aggregate_list', 'rir_id')),
+        ],
         left_panels=[
             panels.AggregatePanel(),
         ],
@@ -779,6 +792,9 @@ class PrefixListView(generic.ObjectListView):
 class PrefixView(generic.ObjectView):
     queryset = Prefix.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('vrf', url=filtered_list_url('ipam:prefix_list', 'vrf_id')),
+        ],
         left_panels=[
             panels.PrefixPanel(),
         ],
@@ -992,8 +1008,12 @@ class IPRangeListView(generic.ObjectListView):
 
 @register_model_view(IPRange)
 class IPRangeView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = IPRange.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('vrf', url=filtered_list_url('ipam:iprange_list', 'vrf_id')),
+        ],
         left_panels=[
             panels.IPRangePanel(),
         ],
@@ -1102,8 +1122,12 @@ class IPAddressListView(generic.ObjectListView):
 
 @register_model_view(IPAddress)
 class IPAddressView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('vrf', url=filtered_list_url('ipam:ipaddress_list', 'vrf_id')),
+        ],
         left_panels=[
             panels.IPAddressPanel(),
             TagsPanel(),
@@ -1308,6 +1332,9 @@ class VLANGroupListView(generic.ObjectListView):
 class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = VLANGroup.objects.annotate_utilization()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('scope'),
+        ],
         left_panels=[
             panels.VLANGroupPanel(),
             TagsPanel(),
@@ -1709,7 +1736,12 @@ class VLANListView(generic.ObjectListView):
 @register_model_view(VLAN)
 class VLANView(generic.ObjectView):
     queryset = VLAN.objects.all()
+    template_name = 'generic/object.html'
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('site', url=filtered_list_url('ipam:vlan_list', 'site_id')),
+            Breadcrumb('group', url=filtered_list_url('ipam:vlan_list', 'group_id')),
+        ],
         left_panels=[
             panels.VLANPanel(),
         ],

+ 250 - 2
netbox/netbox/tests/test_ui.py

@@ -3,8 +3,10 @@ from types import SimpleNamespace
 
 from django.template import Context, Template
 from django.test import RequestFactory, SimpleTestCase, TestCase
+from django.urls import reverse
 from netaddr import IPNetwork
 
+from account.models import UserToken
 from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
 from circuits.models import (
     Provider,
@@ -13,14 +15,17 @@ from circuits.models import (
     VirtualCircuitTermination,
     VirtualCircuitType,
 )
-from core.models import ObjectType
+from core.models import ConfigRevision, ObjectType
 from dcim.choices import InterfaceTypeChoices
-from dcim.models import Interface, Site
+from dcim.models import Interface, Region, Site
 from netbox.ui import attrs
+from netbox.ui.breadcrumbs import Breadcrumb
+from netbox.ui.layout import SimpleLayout
 from netbox.ui.panels import ObjectsTablePanel
 from netbox.ui.utils import build_coords_url
 from users.models import ObjectPermission, User
 from utilities.testing import create_test_device
+from utilities.views import get_view
 from vpn.choices import (
     AuthenticationAlgorithmChoices,
     AuthenticationMethodChoices,
@@ -858,3 +863,246 @@ class ObjectsTablePanelTestCase(TestCase):
         """
         context = self.panel_no_perm.get_context(self._make_context(self.user))
         self.assertFalse(self.panel_no_perm.should_render(context))
+
+
+class BreadcrumbTestCase(SimpleTestCase):
+    """
+    Validate the rendering behavior of the Breadcrumb class.
+    """
+    class _LinkedObject:
+        def __init__(self, label, url, pk=None):
+            self.label = label
+            self._url = url
+            self.pk = pk
+
+        def __str__(self):
+            return self.label
+
+        def get_absolute_url(self):
+            return self._url
+
+    class _PlainObject:
+        def __str__(self):
+            return 'Plain'
+
+    @staticmethod
+    def _render(breadcrumb, instance):
+        return breadcrumb.render({'object': instance})
+
+    def test_accessor_absolute_url(self):
+        """
+        A string accessor resolves the related object, which links to its get_absolute_url() by default.
+        """
+        instance = SimpleNamespace(region=self._LinkedObject('Region 1', '/region/1/'))
+        html = self._render(Breadcrumb('region'), instance)
+        self.assertInHTML('<li class="breadcrumb-item"><a href="/region/1/">Region 1</a></li>', html)
+
+    def test_explicit_url_string(self):
+        """
+        An explicit url string overrides the resolved object's get_absolute_url().
+        """
+        instance = SimpleNamespace(region=self._LinkedObject('Region 1', '/region/1/'))
+        html = self._render(Breadcrumb('region', url='/explicit/'), instance)
+        self.assertInHTML('<li class="breadcrumb-item"><a href="/explicit/">Region 1</a></li>', html)
+
+    def test_url_callable(self):
+        """
+        A callable url is invoked with the resolved object.
+        """
+        instance = SimpleNamespace(region=self._LinkedObject('Region 1', '/region/1/', pk=7))
+        html = self._render(Breadcrumb('region', url=lambda o: f'/list/?region_id={o.pk}'), instance)
+        self.assertInHTML('<li class="breadcrumb-item"><a href="/list/?region_id=7">Region 1</a></li>', html)
+
+    def test_callable_accessor_iterable(self):
+        """
+        A callable accessor resolving to an iterable renders one breadcrumb per object.
+        """
+        a = self._LinkedObject('A', '/a/')
+        b = self._LinkedObject('B', '/b/')
+        html = self._render(Breadcrumb(lambda o: [a, b]), SimpleNamespace())
+        self.assertInHTML('<li class="breadcrumb-item"><a href="/a/">A</a></li>', html)
+        self.assertInHTML('<li class="breadcrumb-item"><a href="/b/">B</a></li>', html)
+
+    def test_no_link(self):
+        """
+        An object with neither an explicit url nor get_absolute_url() renders as a plain label.
+        """
+        html = self._render(Breadcrumb('thing'), SimpleNamespace(thing=self._PlainObject()))
+        self.assertNotIn('<a', html)
+        self.assertInHTML('<li class="breadcrumb-item">Plain</li>', html)
+
+    def test_unresolved_accessor(self):
+        """
+        A breadcrumb whose accessor resolves to None renders as an empty string (and is therefore omitted).
+        """
+        self.assertEqual(self._render(Breadcrumb('region'), SimpleNamespace(region=None)), '')
+
+    def test_empty_iterable(self):
+        """
+        A callable accessor resolving to an empty iterable renders as an empty string.
+        """
+        self.assertEqual(self._render(Breadcrumb(lambda o: []), SimpleNamespace()), '')
+
+    def test_no_instance(self):
+        """
+        With no object in context, the breadcrumb renders as an empty string.
+        """
+        self.assertEqual(Breadcrumb('region').render({}), '')
+
+    def test_static_label_linked(self):
+        """
+        A breadcrumb with a static label and explicit url renders a single fixed crumb, independent of the object.
+        """
+        html = self._render(Breadcrumb(label='My API Tokens', url='/account/tokens/'), SimpleNamespace())
+        self.assertInHTML('<li class="breadcrumb-item"><a href="/account/tokens/">My API Tokens</a></li>', html)
+
+    def test_static_label_no_instance(self):
+        """
+        A static breadcrumb renders even with no object in context.
+        """
+        html = Breadcrumb(label='My API Tokens', url='/account/tokens/').render({})
+        self.assertInHTML('<li class="breadcrumb-item"><a href="/account/tokens/">My API Tokens</a></li>', html)
+
+    def test_static_label_unlinked(self):
+        """
+        A static breadcrumb without a url renders as a plain label and does not fall back to the object's URL.
+        """
+        instance = self._LinkedObject('Token', '/account/tokens/1/')
+        html = self._render(Breadcrumb(label='My API Tokens'), instance)
+        self.assertNotIn('<a', html)
+        self.assertInHTML('<li class="breadcrumb-item">My API Tokens</li>', html)
+
+    def test_callable_label_static(self):
+        """
+        An accessor-less breadcrumb may derive its label from the viewed instance via a callable.
+        """
+        instance = SimpleNamespace(unit_list='1-5')
+        html = self._render(Breadcrumb(label=lambda o: f'Units {o.unit_list}'), instance)
+        self.assertNotIn('<a', html)
+        self.assertInHTML('<li class="breadcrumb-item">Units 1-5</li>', html)
+
+    def test_callable_label_with_accessor(self):
+        """
+        With an accessor, a callable label receives the resolved related object (overriding its string value).
+        """
+        instance = SimpleNamespace(rack=self._LinkedObject('Rack 1', '/rack/1/'))
+        html = self._render(Breadcrumb('rack', label=lambda o: f'Rack: {o}'), instance)
+        self.assertInHTML('<li class="breadcrumb-item"><a href="/rack/1/">Rack: Rack 1</a></li>', html)
+
+    def test_no_accessor_or_label_raises(self):
+        """
+        A breadcrumb must define either an accessor or a static label.
+        """
+        with self.assertRaises(ValueError):
+            Breadcrumb()
+
+
+class LayoutBreadcrumbsTestCase(SimpleTestCase):
+    """
+    Validate that a layout stores and validates the breadcrumbs declared on it.
+    """
+    def test_breadcrumbs_stored(self):
+        crumbs = [Breadcrumb('region'), Breadcrumb('group')]
+        self.assertEqual(SimpleLayout(breadcrumbs=crumbs).breadcrumbs, crumbs)
+
+    def test_breadcrumbs_default_empty(self):
+        self.assertEqual(SimpleLayout().breadcrumbs, [])
+
+    def test_invalid_breadcrumb_raises(self):
+        with self.assertRaises(TypeError):
+            SimpleLayout(breadcrumbs=['not a breadcrumb'])
+
+    def test_root_breadcrumb_default_true(self):
+        self.assertTrue(SimpleLayout().root_breadcrumb)
+
+    def test_root_breadcrumb_opt_out(self):
+        self.assertFalse(SimpleLayout(root_breadcrumb=False).root_breadcrumb)
+
+
+class GetViewTestCase(SimpleTestCase):
+    """
+    Validate the get_view() utility used to resolve a model's registered views.
+    """
+    def test_base_view(self):
+        view = get_view(Site)
+        self.assertIsNotNone(view)
+        self.assertEqual(view.queryset.model, Site)
+
+    def test_accepts_instance(self):
+        # A model instance resolves to the same view as its class
+        self.assertIs(get_view(Site(name='Site 1')), get_view(Site))
+
+    def test_unknown_name_returns_none(self):
+        self.assertIsNone(get_view(Site, 'this-view-does-not-exist'))
+
+
+class RenderBreadcrumbsTagTestCase(SimpleTestCase):
+    """
+    Validate the render_breadcrumbs template tag's handling of objects without a breadcrumb trail.
+    """
+    @staticmethod
+    def _render(obj):
+        template = Template('{% render_breadcrumbs %}')
+        return template.render(Context({'object': obj}))
+
+    def test_none_object(self):
+        self.assertEqual(self._render(None), '')
+
+    def test_non_model_object(self):
+        # An object lacking _meta (e.g. an RQ worker) must not raise
+        self.assertEqual(self._render(SimpleNamespace(name='not a model')), '')
+
+    def test_default_root_breadcrumb(self):
+        # A model whose base view declares no custom breadcrumbs still renders the default root crumb:
+        # a link to its list view, labeled with the model's plural name.
+        html = self._render(ObjectPermission())
+        self.assertInHTML('<li class="breadcrumb-item"><a href="/users/permissions/">Permissions</a></li>', html)
+
+    def test_opt_out_root_breadcrumb(self):
+        # A layout with root_breadcrumb=False suppresses the default root crumb, leaving only its own
+        # breadcrumbs (here UserTokenView's static "My API Tokens" crumb) to stand in its place.
+        html = self._render(UserToken())
+        self.assertEqual(html.count('breadcrumb-item'), 1)
+        self.assertInHTML(
+            f'<li class="breadcrumb-item"><a href="{reverse("account:usertoken_list")}">My API Tokens</a></li>', html
+        )
+
+    def test_opt_out_without_breadcrumbs(self):
+        # A layout with root_breadcrumb=False and no breadcrumbs of its own (e.g. ConfigRevisionView) renders
+        # an empty trail.
+        self.assertEqual(self._render(ConfigRevision()), '')
+
+
+class RenderBreadcrumbsTagRenderTestCase(TestCase):
+    """
+    Validate that the render_breadcrumbs tag resolves and renders the trail declared on a model's base view layout.
+    """
+    @staticmethod
+    def _render(instance):
+        template = Template('{% render_breadcrumbs %}')
+        return template.render(Context({'object': instance}))
+
+    def test_region_ancestor_breadcrumbs(self):
+        grandparent = Region.objects.create(name='Grandparent Region', slug='grandparent-region')
+        parent = Region.objects.create(name='Parent Region', slug='parent-region', parent=grandparent)
+        child = Region.objects.create(name='Child Region', slug='child-region', parent=parent)
+
+        html = self._render(child)
+        list_url = reverse('dcim:region_list')
+        # The default root crumb is rendered ahead of the layout-defined ancestor crumbs
+        self.assertInHTML(f'<li class="breadcrumb-item"><a href="{list_url}">Regions</a></li>', html)
+        self.assertInHTML(
+            f'<li class="breadcrumb-item"><a href="{list_url}?parent_id={grandparent.pk}">{grandparent}</a></li>', html
+        )
+        self.assertInHTML(
+            f'<li class="breadcrumb-item"><a href="{list_url}?parent_id={parent.pk}">{parent}</a></li>', html
+        )
+
+    def test_region_without_ancestors(self):
+        # A top-level object's ancestor accessor resolves to an empty queryset, leaving only the default root crumb
+        region = Region.objects.create(name='Top Region', slug='top-region')
+        html = self._render(region)
+        self.assertEqual(html.count('breadcrumb-item'), 1)
+        self.assertInHTML(
+            f'<li class="breadcrumb-item"><a href="{reverse("dcim:region_list")}">Regions</a></li>', html
+        )

+ 152 - 0
netbox/netbox/ui/breadcrumbs.py

@@ -0,0 +1,152 @@
+from django.template.loader import render_to_string
+from django.urls import NoReverseMatch, reverse
+
+from utilities.data import resolve_attr_path
+
+__all__ = (
+    'Breadcrumb',
+    'filtered_list_url',
+    'get_root_breadcrumb',
+    'object_view_url',
+)
+
+
+def filtered_list_url(viewname, filter_param):
+    """
+    Return a callable suitable for a `Breadcrumb`'s `url`, linking to a list view filtered by the
+    resolved object's primary key. For example, `filtered_list_url('dcim:rack_list', 'site_id')`
+    produces a URL of the form `{rack_list}?site_id=<pk>`.
+    """
+    return lambda obj: f"{reverse(viewname)}?{filter_param}={obj.pk}"
+
+
+def object_view_url(viewname):
+    """
+    Return a callable suitable for a `Breadcrumb`'s `url`, linking to a view keyed by the resolved
+    object's primary key. For example, `object_view_url('dcim:device_interfaces')` produces the URL
+    `reverse('dcim:device_interfaces', kwargs={'pk': <pk>})`.
+    """
+    return lambda obj: reverse(viewname, kwargs={'pk': obj.pk})
+
+
+def get_root_breadcrumb(instance):
+    """
+    Return the default root `Breadcrumb` for a model instance: a link to the model's list view,
+    labeled with the model's plural verbose name. This is prepended automatically to every object
+    view's trail unless the view's layout opts out via `root_breadcrumb=False`.
+    """
+    from utilities.templatetags.builtins.filters import bettertitle
+    from utilities.views import get_action_url
+
+    label = bettertitle(instance._meta.verbose_name_plural)
+    try:
+        url = get_action_url(instance, 'list')
+    except NoReverseMatch:
+        url = None
+    return Breadcrumb(label=label, url=url)
+
+
+class Breadcrumb:
+    """
+    A navigation breadcrumb rendered at the top of an object view.
+
+    Rather than wrapping a static value, a breadcrumb typically references an attribute on the object being viewed.
+    This allows breadcrumbs to be declared once on a layout (alongside its panels) and rendered dynamically for each
+    object. A breadcrumb whose resolved value is empty renders as an empty string and is omitted, which simplifies
+    conditional breadcrumbs (e.g. where a device may or may not be assigned to a rack).
+
+    A breadcrumb may instead define a static `label`, omitting the accessor entirely. This renders a single
+    breadcrumb describing the viewed object directly (or a fixed destination) rather than a related object, which is
+    useful for linking to a parent view that isn't a related object (e.g. a user's personal token list) or for an
+    unlinked descriptive crumb (e.g. "Units 1-5" on a rack reservation).
+
+    Attributes:
+        template_name (str): The name of the template used to render the breadcrumb
+
+    Parameters:
+        accessor: The dotted path to the related object on the viewed instance (e.g. "site" or "device.rack"),
+            or a callable which accepts the instance and returns the related object. If the resolved value is an
+            iterable of objects, a breadcrumb is rendered for each (e.g. to represent a hierarchy of ancestors).
+            Omit this (and pass `label`) to render a single breadcrumb describing the viewed object itself.
+        label: A label for the breadcrumb. Required when `accessor` is omitted. May be a string, or a callable
+            which accepts the relevant object (the resolved related object when an accessor is given, otherwise the
+            viewed instance) and returns the label. When an accessor is given and no label is set, the resolved
+            object's string representation is used.
+        url: An optional URL for the breadcrumb's link. May be a string, or a callable which accepts the relevant
+            object and returns a URL. When an accessor is given and no url is set, the resolved object's
+            `get_absolute_url()` is used where available; an accessor-less breadcrumb is left unlinked instead.
+    """
+    template_name = 'ui/breadcrumb.html'
+
+    def __init__(self, accessor=None, label=None, url=None):
+        if accessor is None and label is None:
+            raise ValueError("A Breadcrumb must define an accessor, a static label, or both.")
+        self.accessor = accessor
+        self.label = label
+        self.url = url
+
+    def resolve(self, instance):
+        """
+        Resolve the breadcrumb's accessor against the viewed instance and return the related object(s).
+        """
+        if callable(self.accessor):
+            return self.accessor(instance)
+        return resolve_attr_path(instance, self.accessor)
+
+    def get_label(self, obj):
+        """
+        Return the breadcrumb's label for the given object, falling back to its string representation.
+        """
+        if callable(self.label):
+            return self.label(obj) if obj is not None else None
+        if self.label is not None:
+            return self.label
+        return str(obj) if obj is not None else None
+
+    def get_url(self, obj, fallback=True):
+        """
+        Return the URL to link the given object to, or None for an unlinked breadcrumb. When `fallback` is True,
+        an object's `get_absolute_url()` is used in the absence of an explicit url.
+        """
+        if self.url is not None:
+            return self.url(obj) if callable(self.url) else self.url
+        if fallback and obj is not None and hasattr(obj, 'get_absolute_url'):
+            return obj.get_absolute_url()
+        return None
+
+    def render(self, context=None):
+        instance = context.get('object') if context else None
+
+        # A breadcrumb without an accessor describes the viewed object directly (or a fixed destination), rather
+        # than a related object, and renders a single crumb. It is left unlinked unless an explicit url is given.
+        if self.accessor is None:
+            label = self.get_label(instance)
+            if not label:
+                return ''
+            return render_to_string(self.template_name, {
+                'url': self.get_url(instance, fallback=False),
+                'label': label,
+            })
+
+        if instance is None:
+            return ''
+        value = self.resolve(instance)
+        if value is None:
+            return ''
+
+        # A resolved iterable (e.g. a queryset of ancestors) yields one breadcrumb per object
+        objects = value if self._is_iterable(value) else [value]
+
+        return ''.join(
+            render_to_string(self.template_name, {
+                'url': self.get_url(obj),
+                'label': self.get_label(obj),
+            })
+            for obj in objects if obj is not None
+        )
+
+    @staticmethod
+    def _is_iterable(value):
+        if isinstance(value, (str, bytes)):
+            return False
+        return hasattr(value, '__iter__')

+ 19 - 3
netbox/netbox/ui/layout.py

@@ -1,3 +1,4 @@
+from netbox.ui.breadcrumbs import Breadcrumb
 from netbox.ui.panels import Panel, PluginContentPanel
 
 __all__ = (
@@ -18,12 +19,24 @@ class Layout:
 
     Parameters:
         *rows: One or more Row instances
+        breadcrumbs: An ordered iterable of `Breadcrumb` instances rendered at the top of the page, after the
+            default breadcrumb linking to the object's list view. The trail defined on a model's base view is
+            shared across all of that model's object views (e.g. its detail view and every peer/tabbed view).
+        root_breadcrumb: Whether to prepend the default root breadcrumb (a link to the object's list view) to
+            the trail. Set False for views whose list view is not an appropriate root (e.g. a user's personal
+            token list), allowing the layout's own breadcrumbs to stand in its place.
     """
-    def __init__(self, *rows):
+    def __init__(self, *rows, breadcrumbs=None, root_breadcrumb=True):
         for i, row in enumerate(rows):
             if not isinstance(row, Row):
                 raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.")
+        breadcrumbs = breadcrumbs or []
+        for i, breadcrumb in enumerate(breadcrumbs):
+            if not isinstance(breadcrumb, Breadcrumb):
+                raise TypeError(f"Breadcrumb {i} must be a Breadcrumb instance, not {type(breadcrumb)}.")
         self.rows = rows
+        self.breadcrumbs = breadcrumbs
+        self.root_breadcrumb = root_breadcrumb
 
     def __iter__(self):
         return iter(self.rows)
@@ -102,8 +115,11 @@ class SimpleLayout(Layout):
         left_panels: Panel instances to be rendered in the top lefthand column
         right_panels: Panel instances to be rendered in the top righthand column
         bottom_panels: Panel instances to be rendered in the bottom row
+        breadcrumbs: Breadcrumb instances rendered at the top of the page (see Layout)
+        root_breadcrumb: Whether to prepend the default root breadcrumb (see Layout)
     """
-    def __init__(self, left_panels=None, right_panels=None, bottom_panels=None):
+    def __init__(self, left_panels=None, right_panels=None, bottom_panels=None, breadcrumbs=None,
+                 root_breadcrumb=True):
         left_panels = left_panels or []
         right_panels = right_panels or []
         bottom_panels = bottom_panels or []
@@ -116,4 +132,4 @@ class SimpleLayout(Layout):
                 Column(*bottom_panels, PluginContentPanel('full_width_page'))
             )
         ]
-        super().__init__(*rows)
+        super().__init__(*rows, breadcrumbs=breadcrumbs, root_breadcrumb=root_breadcrumb)

+ 0 - 8
netbox/templates/account/token.html

@@ -1,8 +0,0 @@
-{% extends 'users/token.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  <li class="breadcrumb-item">
-    <a href="{% url 'account:usertoken_list' %}">{% trans "My API Tokens" %}</a>
-  </li>
-{% endblock breadcrumbs %}

+ 0 - 6
netbox/templates/circuits/circuit.html

@@ -1,6 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.provider.pk }}">{{ object.provider }}</a></li>
-{% endblock %}

+ 0 - 5
netbox/templates/circuits/circuitgroup.html

@@ -1,11 +1,6 @@
 {% extends 'generic/object.html' %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'circuits:circuitgroup_list' %}?circuitgroup_id={{ object.id }}">{{ object.name }}</a></li>
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.circuit.add_circuitgroupassignment %}
     <a href="{% url 'circuits:circuitgroupassignment_add' %}?group={{ object.pk }}" class="btn btn-primary">

+ 0 - 8
netbox/templates/circuits/circuitgroupassignment.html

@@ -1,8 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'circuits:circuitgroupassignment_list' %}?group_id={{ object.group_id }}">{{ object.group }}</a>
-  </li>
-{% endblock %}

+ 0 - 9
netbox/templates/circuits/circuittermination.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.circuit.provider.pk }}">{{ object.circuit.provider }}</a></li>
-{% endblock %}

+ 0 - 6
netbox/templates/circuits/provideraccount.html

@@ -1,6 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'circuits:provideraccount_list' %}?provider_id={{ object.provider_id }}">{{ object.provider }}</a></li>
-{% endblock %}

+ 0 - 6
netbox/templates/circuits/providernetwork.html

@@ -1,6 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'circuits:providernetwork_list' %}?provider_id={{ object.provider_id }}">{{ object.provider }}</a></li>
-{% endblock %}

+ 0 - 11
netbox/templates/circuits/virtualcircuit.html

@@ -1,11 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_id={{ object.provider.pk }}">{{ object.provider }}</a>
-  </li>
-  <li class="breadcrumb-item">
-    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_network_id={{ object.provider_network.pk }}">{{ object.provider_network }}</a>
-  </li>
-{% endblock %}

+ 0 - 15
netbox/templates/circuits/virtualcircuittermination.html

@@ -1,15 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_id={{ object.virtual_circuit.provider.pk }}">{{ object.virtual_circuit.provider }}</a>
-  </li>
-  <li class="breadcrumb-item">
-    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_network_id={{ object.virtual_circuit.provider_network.pk }}">{{ object.virtual_circuit.provider_network }}</a>
-  </li>
-  <li class="breadcrumb-item">
-    <a href="{% url 'circuits:virtualcircuittermination_list' %}?virtual_circuit_id={{ object.virtual_circuit.pk }}">{{ object.virtual_circuit }}</a>
-  </li>
-{% endblock %}

+ 0 - 3
netbox/templates/core/configrevision.html

@@ -4,9 +4,6 @@
 {% load perms %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-{% endblock %}
-
 {% block control-buttons %}
   {% if not object.pk or object.is_active and perms.core.add_configrevision %}
     {% url 'core:configrevision_add' as edit_url %}

+ 0 - 7
netbox/templates/core/datafile.html

@@ -1,7 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
-{% endblock %}

+ 0 - 4
netbox/templates/dcim/cablebundle.html

@@ -3,10 +3,6 @@
 {% load plugins %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  <li class="breadcrumb-item"><a href="{% url 'dcim:cablebundle_list' %}">{% trans "Cable Bundles" %}</a></li>
-{% endblock %}
-
 {% block content %}
   <div class="row mb-3">
     <div class="col col-12 col-md-6">

+ 0 - 9
netbox/templates/dcim/consoleport.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:device_consoleports' pk=object.device.pk %}">{{ object.device }}</a>
-  </li>
-{% endblock %}

+ 0 - 9
netbox/templates/dcim/consoleserverport.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:device_consoleserverports' pk=object.device.pk %}">{{ object.device }}</a>
-  </li>
-{% endblock %}

+ 0 - 9
netbox/templates/dcim/devicebay.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:device_devicebays' pk=object.device.pk %}">{{ object.device }}</a>
-  </li>
-{% endblock %}

+ 0 - 7
netbox/templates/dcim/devicerole.html

@@ -1,13 +1,6 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  <li class="breadcrumb-item"><a href="{% url 'dcim:devicerole_list' %}">{% trans "Device Roles" %}</a></li>
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.dcim.add_device %}
     <a href="{% url 'dcim:device_add' %}?role={{ object.pk }}" class="btn btn-primary">

+ 0 - 5
netbox/templates/dcim/devicetype/base.html

@@ -6,11 +6,6 @@
 
 {% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'dcim:devicetype_list' %}?manufacturer_id={{ object.manufacturer.pk }}">{{ object.manufacturer }}</a></li>
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.dcim.change_devicetype %}
     <div class="dropdown">

+ 0 - 9
netbox/templates/dcim/frontport.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:device_frontports' pk=object.device.pk %}">{{ object.device }}</a>
-  </li>
-{% endblock %}

+ 0 - 7
netbox/templates/dcim/interface.html

@@ -1,13 +1,6 @@
 {% extends 'generic/object.html' %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:device_interfaces' pk=object.device.pk %}">{{ object.device }}</a>
-  </li>
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.dcim.add_interface and not object.is_virtual %}
     <a href="{% url 'dcim:interface_add' %}?device={{ object.device.pk }}&parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-success">

+ 0 - 9
netbox/templates/dcim/inventoryitem.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:device_inventory' pk=object.device.pk %}">{{ object.device }}</a>
-  </li>
-{% endblock %}

+ 0 - 8
netbox/templates/dcim/location.html

@@ -1,8 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% for location in object.get_ancestors %}
-    <li class="breadcrumb-item">{{ location|linkify }}</li>
-  {% endfor %}
-{% endblock %}

+ 0 - 7
netbox/templates/dcim/module.html

@@ -3,13 +3,6 @@
 {% load plugins %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:module_list' %}?module_type_id={{ object.module_type.pk }}">{{ object.module_type }}</a>
-  </li>
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.dcim.change_module %}
     <div class="dropdown">

+ 0 - 9
netbox/templates/dcim/modulebay.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:device_modulebays' pk=object.device.pk %}">{{ object.device }}</a>
-  </li>
-{% endblock %}

+ 0 - 7
netbox/templates/dcim/moduletype.html

@@ -3,13 +3,6 @@
 
 {% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:moduletype_list' %}?manufacturer_id={{ object.manufacturer.pk }}">{{ object.manufacturer }}</a>
-  </li>
-{% endblock %}
-
 {% block extra_controls %}
   {% include 'dcim/inc/moduletype_buttons.html' %}
 {% endblock %}

+ 0 - 7
netbox/templates/dcim/platform.html

@@ -4,13 +4,6 @@
 {% load render_table from django_tables2 %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% if object.manufacturer %}
-    <li class="breadcrumb-item"><a href="{% url 'dcim:platform_list' %}?manufacturer_id={{ object.manufacturer.pk }}">{{ object.manufacturer }}</a></li>
-  {% endif %}
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.dcim.add_device %}
     <a href="{% url 'dcim:device_add' %}?platform={{ object.pk }}" class="btn btn-primary">

+ 0 - 10
netbox/templates/dcim/powerfeed.html

@@ -1,10 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'dcim:powerfeed_list' %}?site_id={{ object.power_panel.site.pk }}">{{ object.power_panel.site }}</a></li>
-  <li class="breadcrumb-item"><a href="{% url 'dcim:powerfeed_list' %}?power_panel_id={{ object.power_panel.pk }}">{{ object.power_panel }}</a></li>
-  {% if object.rack %}
-    <li class="breadcrumb-item"><a href="{% url 'dcim:powerfeed_list' %}?rack_id={{ object.rack.pk }}">{{ object.rack }}</a></li>
-  {% endif %}
-{% endblock %}

+ 0 - 9
netbox/templates/dcim/poweroutlet.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:device_poweroutlets' pk=object.device.pk %}">{{ object.device }}</a>
-  </li>
-{% endblock %}

+ 0 - 9
netbox/templates/dcim/powerpanel.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'dcim:powerpanel_list' %}?site_id={{ object.site.pk }}">{{ object.site }}</a></li>
-  {% if object.location %}
-    <li class="breadcrumb-item">{{ object.location|linkify }}</li>
-  {% endif %}
-{% endblock %}

+ 0 - 9
netbox/templates/dcim/powerport.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:device_powerports' pk=object.device.pk %}">{{ object.device }}</a>
-  </li>
-{% endblock %}

+ 0 - 11
netbox/templates/dcim/rack/base.html

@@ -3,17 +3,6 @@
 
 {% block title %}{% trans "Rack" %} {{ object }}{% endblock %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'dcim:rack_list' %}?site_id={{ object.site.pk }}">{{ object.site }}</a></li>
-  {% if object.location %}
-    {% for location in object.location.get_ancestors %}
-      <li class="breadcrumb-item"><a href="{% url 'dcim:rack_list' %}?location_id={{ location.pk }}">{{ location }}</a></li>
-    {% endfor %}
-    <li class="breadcrumb-item"><a href="{% url 'dcim:rack_list' %}?location_id={{ object.location.pk }}">{{ object.location }}</a></li>
-  {% endif %}
-{% endblock %}
-
 {% block extra_controls %}
   <div class="btn-group" role="group" aria-label="RackNavigation">
     {% if prev_rack %}

+ 0 - 8
netbox/templates/dcim/rackreservation.html

@@ -1,8 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'dcim:rackreservation_list' %}?rack_id={{ object.rack.pk }}">{{ object.rack }}</a></li>
-  <li class="breadcrumb-item">{% trans "Units" %} {{ object.unit_list }}</li>
-{% endblock %}

+ 0 - 9
netbox/templates/dcim/rearport.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'dcim:device_rearports' pk=object.device.pk %}">{{ object.device }}</a>
-  </li>
-{% endblock %}

+ 0 - 7
netbox/templates/dcim/region.html

@@ -1,13 +1,6 @@
 {% extends 'generic/object.html' %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% for region in object.get_ancestors %}
-    <li class="breadcrumb-item"><a href="{% url 'dcim:region_list' %}?parent_id={{ region.pk }}">{{ region }}</a></li>
-  {% endfor %}
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.dcim.add_site %}
     <a href="{% url 'dcim:site_add' %}?region={{ object.pk }}" class="btn btn-primary">

+ 0 - 7
netbox/templates/dcim/sitegroup.html

@@ -1,13 +1,6 @@
 {% extends 'generic/object.html' %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% for sitegroup in object.get_ancestors %}
-    <li class="breadcrumb-item"><a href="{% url 'dcim:sitegroup_list' %}?parent_id={{ sitegroup.pk }}">{{ sitegroup }}</a></li>
-  {% endfor %}
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.dcim.add_site %}
     <a href="{% url 'dcim:site_add' %}?group={{ object.pk }}" class="btn btn-primary">

+ 1 - 5
netbox/templates/generic/object.html

@@ -31,11 +31,7 @@ Context:
 
       {# Breadcrumbs #}
       <ol class="breadcrumb" aria-label="breadcrumbs">
-        {% block breadcrumbs %}
-          <li class="breadcrumb-item">
-            <a href="{% action_url object 'list' %}">{{ object|meta:'verbose_name_plural'|bettertitle }}</a>
-          </li>
-        {% endblock breadcrumbs %}
+        {% block breadcrumbs %}{% render_breadcrumbs %}{% endblock breadcrumbs %}
       </ol>
 
       {# Object identifier #}

+ 0 - 1
netbox/templates/ipam/aggregate.html

@@ -1 +0,0 @@
-{% extends 'ipam/aggregate/base.html' %}

+ 0 - 8
netbox/templates/ipam/aggregate/base.html

@@ -1,8 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'ipam:aggregate_list' %}?rir_id={{ object.rir.pk }}">{{ object.rir }}</a></li>
-{% endblock %}

+ 0 - 10
netbox/templates/ipam/asn.html

@@ -1,10 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'ipam:asn_list' %}?rir_id={{ object.rir.pk }}">{{ object.rir }}</a></li>
-  {% if object.range %}
-    <li class="breadcrumb-item"><a href="{% url 'ipam:asn_list' %}?range_id={{ object.range.pk }}">{{ object.range }}</a></li>
-  {% endif %}
-{% endblock breadcrumbs %}

+ 0 - 1
netbox/templates/ipam/asnrange.html

@@ -1 +0,0 @@
-{% extends 'ipam/asnrange/base.html' %}

+ 0 - 6
netbox/templates/ipam/asnrange/base.html

@@ -1,6 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'ipam:asnrange_list' %}?rir_id={{ object.rir.pk }}">{{ object.rir }}</a></li>
-{% endblock breadcrumbs %}

+ 0 - 1
netbox/templates/ipam/ipaddress.html

@@ -1 +0,0 @@
-{% extends 'ipam/ipaddress/base.html' %}

+ 0 - 8
netbox/templates/ipam/ipaddress/base.html

@@ -1,8 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% if object.vrf %}
-    <li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
-  {% endif %}
-{% endblock %}

+ 0 - 1
netbox/templates/ipam/iprange.html

@@ -1 +0,0 @@
-{% extends 'ipam/iprange/base.html' %}

+ 0 - 10
netbox/templates/ipam/iprange/base.html

@@ -1,10 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% if object.vrf %}
-    <li class="breadcrumb-item"><a href="{% url 'ipam:iprange_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
-  {% endif %}
-{% endblock %}

+ 0 - 7
netbox/templates/ipam/prefix/base.html

@@ -3,13 +3,6 @@
 {% load helpers %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% if object.vrf %}
-    <li class="breadcrumb-item"><a href="{% url 'ipam:prefix_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
-  {% endif %}
-{% endblock %}
-
 {% block modals %}
   {{ block.super }}
   {% if object.prefix.version == 4 %}

+ 0 - 1
netbox/templates/ipam/vlan.html

@@ -1 +0,0 @@
-{% extends 'ipam/vlan/base.html' %}

+ 0 - 16
netbox/templates/ipam/vlan/base.html

@@ -1,16 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-
-{% block title %}{% trans "VLAN" %} {{ object }}{% endblock %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% if object.site %}
-    <li class="breadcrumb-item"><a href="{% url 'ipam:vlan_list' %}?site_id={{ object.site.pk }}">{{ object.site }}</a></li>
-  {% endif %}
-  {% if object.group %}
-    <li class="breadcrumb-item"><a href="{% url 'ipam:vlan_list' %}?group_id={{ object.group.pk }}">{{ object.group }}</a></li>
-  {% endif %}
-{% endblock %}

+ 0 - 8
netbox/templates/ipam/vlangroup.html

@@ -2,14 +2,6 @@
 {% load helpers %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% if object.scope %}
-    {# TODO: This should link to a filtered list of VLANGroups #}
-    <li class="breadcrumb-item">{{ object.scope|linkify }}</li>
-  {% endif %}
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.ipam.add_vlan %}
     <a href="{% url 'ipam:vlan_add' %}?group={{ object.pk }}" class="btn btn-primary">

+ 0 - 8
netbox/templates/tenancy/contactgroup.html

@@ -1,8 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% for ancestor in object.get_ancestors %}
-    <li class="breadcrumb-item"><a href="{% url 'tenancy:contactgroup_list' %}?parent_id={{ ancestor.pk }}">{{ ancestor }}</a></li>
-  {% endfor %}
-{% endblock %}

+ 0 - 11
netbox/templates/tenancy/tenant.html

@@ -1,11 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% if object.group %}
-    {% for group in object.group.get_ancestors %}
-      <li class="breadcrumb-item"><a href="{% url 'tenancy:tenant_list' %}?group_id={{ group.pk }}">{{ group }}</a></li>
-    {% endfor %}
-    <li class="breadcrumb-item"><a href="{% url 'tenancy:tenant_list' %}?group_id={{ object.group.pk }}">{{ object.group }}</a></li>
-  {% endif %}
-{% endblock %}

+ 0 - 7
netbox/templates/tenancy/tenantgroup.html

@@ -1,13 +1,6 @@
 {% extends 'generic/object.html' %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% for ancestor in object.get_ancestors %}
-    <li class="breadcrumb-item"><a href="{% url 'tenancy:tenantgroup_list' %}?parent_id={{ ancestor.pk }}">{{ ancestor }}</a></li>
-  {% endfor %}
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.tenancy.add_tenant %}
     <a href="{% url 'tenancy:tenant_add' %}?group={{ object.pk }}" class="btn btn-primary">

+ 3 - 0
netbox/templates/ui/breadcrumb.html

@@ -0,0 +1,3 @@
+<li class="breadcrumb-item">
+  {% if url %}<a href="{{ url }}">{{ label }}</a>{% else %}{{ label }}{% endif %}
+</li>

+ 0 - 8
netbox/templates/virtualization/cluster/base.html

@@ -4,14 +4,6 @@
 {% load plugins %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'virtualization:cluster_list' %}?type_id={{ object.type.pk }}">{{ object.type }}</a></li>
-  {% if object.group %}
-    <li class="breadcrumb-item"><a href="{% url 'virtualization:cluster_list' %}?group_id={{ object.group.pk }}">{{ object.group }}</a></li>
-  {% endif %}
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.virtualization.change_cluster and perms.virtualization.add_virtualmachine %}
     <a href="{% url 'virtualization:virtualmachine_add' %}?cluster={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary">

+ 0 - 12
netbox/templates/virtualization/virtualdisk.html

@@ -1,12 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-     <a href="{% url 'virtualization:virtualmachine_disks' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
-  </li>
-{% endblock %}

+ 0 - 8
netbox/templates/virtualization/vminterface.html

@@ -1,8 +0,0 @@
-{% extends 'generic/object.html' %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item">
-    <a href="{% url 'virtualization:virtualmachine_interfaces' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
-  </li>
-{% endblock %}

+ 0 - 7
netbox/templates/wireless/wirelesslangroup.html

@@ -1,13 +1,6 @@
 {% extends 'generic/object.html' %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  {% for group in object.get_ancestors %}
-    <li class="breadcrumb-item"><a href="{% url 'wireless:wirelesslangroup_list' %}?parent_id={{ group.pk }}">{{ group }}</a></li>
-  {% endfor %}
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.wireless.add_wirelesslan %}
     <a href="{% url 'wireless:wirelesslan_add' %}?group={{ object.pk }}" class="btn btn-primary">

+ 21 - 0
netbox/tenancy/views.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 from extras.ui.panels import CustomFieldsPanel, TagsPanel
 from netbox.object_actions import BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.ui import actions, layout
+from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url
 from netbox.ui.panels import (
     CommentsPanel,
     NestedGroupObjectPanel,
@@ -43,6 +44,12 @@ class TenantGroupListView(generic.ObjectListView):
 class TenantGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = TenantGroup.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                lambda o: o.get_ancestors(),
+                url=filtered_list_url('tenancy:tenantgroup_list', 'parent_id'),
+            ),
+        ],
         left_panels=[
             NestedGroupObjectPanel(),
             TagsPanel(),
@@ -142,8 +149,15 @@ class TenantListView(generic.ObjectListView):
 
 @register_model_view(Tenant)
 class TenantView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = Tenant.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                lambda o: o.group.get_ancestors(include_self=True) if o.group else [],
+                url=filtered_list_url('tenancy:tenant_list', 'group_id'),
+            ),
+        ],
         left_panels=[
             panels.TenantPanel(),
             CustomFieldsPanel(),
@@ -214,8 +228,15 @@ class ContactGroupListView(generic.ObjectListView):
 
 @register_model_view(ContactGroup)
 class ContactGroupView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ContactGroup.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                lambda o: o.get_ancestors(),
+                url=filtered_list_url('tenancy:contactgroup_list', 'parent_id'),
+            ),
+        ],
         left_panels=[
             NestedGroupObjectPanel(),
             TagsPanel(),

+ 28 - 0
netbox/utilities/templatetags/builtins/tags.py

@@ -201,3 +201,31 @@ def render(context, component):
     Render a UI component (e.g. a Panel) by calling its render() method and passing the current template context.
     """
     return mark_safe(component.render(context))
+
+
+@register.simple_tag(takes_context=True)
+def render_breadcrumbs(context):
+    """
+    Render the breadcrumb trail for the current object. The trail comprises a default root breadcrumb
+    (a link to the object's list view) followed by any breadcrumbs defined on the layout of the object's
+    base (detail) view. Resolving the trail from the base view—rather than the view currently rendering—
+    ensures that an object's detail view and all of its peer/tabbed views render the same trail. A layout
+    may suppress the default root breadcrumb (e.g. to substitute its own) via `root_breadcrumb=False`.
+    """
+    from netbox.ui.breadcrumbs import get_root_breadcrumb
+    from utilities.views import get_view
+
+    obj = context.get('object')
+    # The object on some pages (e.g. RQ workers/tasks) is not a model instance and has no associated view
+    if obj is None or not hasattr(obj, '_meta'):
+        return ''
+
+    # Pull the breadcrumbs from the layout of the object's base (detail) view
+    layout = getattr(get_view(obj), 'layout', None)
+    breadcrumbs = list(getattr(layout, 'breadcrumbs', None) or [])
+
+    # Prepend the default root breadcrumb unless the layout opts out
+    if getattr(layout, 'root_breadcrumb', True):
+        breadcrumbs.insert(0, get_root_breadcrumb(obj))
+
+    return mark_safe(''.join(breadcrumb.render(context) for breadcrumb in breadcrumbs))

+ 16 - 0
netbox/utilities/views.py

@@ -32,6 +32,7 @@ __all__ = (
     'ViewTab',
     'get_action_url',
     'get_default_template',
+    'get_view',
     'get_viewname',
     'register_model_view',
 )
@@ -389,3 +390,18 @@ def register_model_view(model, name='', path=None, detail=True, kwargs=None):
         return cls
 
     return _wrapper
+
+
+def get_view(model, name=''):
+    """
+    Return the view class registered for a model under the given name, or None if no matching view is registered.
+
+    Args:
+        model: A model class or instance whose registered view should be returned.
+        name: The name under which the view was registered (see `register_model_view()`). Defaults to the
+            model's base (detail) view.
+    """
+    app_label = model._meta.app_label
+    model_name = model._meta.model_name
+    views = registry['views'].get(app_label, {}).get(model_name, [])
+    return next((v['view'] for v in views if v['name'] == name), None)

+ 19 - 0
netbox/virtualization/views.py

@@ -26,6 +26,7 @@ from netbox.object_actions import (
     EditObject,
 )
 from netbox.ui import actions, layout
+from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url, object_view_url
 from netbox.ui.panels import (
     CommentsPanel,
     ContextTablePanel,
@@ -233,6 +234,10 @@ class ClusterListView(generic.ObjectListView):
 class ClusterView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Cluster.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('type', url=filtered_list_url('virtualization:cluster_list', 'type_id')),
+            Breadcrumb('group', url=filtered_list_url('virtualization:cluster_list', 'group_id')),
+        ],
         left_panels=[
             panels.ClusterPanel(),
             CommentsPanel(),
@@ -636,8 +641,15 @@ class VMInterfaceListView(generic.ObjectListView):
 
 @register_model_view(VMInterface)
 class VMInterfaceView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = VMInterface.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                'virtual_machine',
+                url=object_view_url('virtualization:virtualmachine_interfaces'),
+            ),
+        ],
         left_panels=[
             panels.VMInterfacePanel(),
             TagsPanel(),
@@ -768,8 +780,15 @@ class VirtualDiskListView(generic.ObjectListView):
 
 @register_model_view(VirtualDisk)
 class VirtualDiskView(generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = VirtualDisk.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                'virtual_machine',
+                url=object_view_url('virtualization:virtualmachine_disks'),
+            ),
+        ],
         left_panels=[
             panels.VirtualDiskPanel(),
             TagsPanel(),

+ 7 - 0
netbox/wireless/views.py

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.models import Interface
 from extras.ui.panels import CustomFieldsPanel, TagsPanel
 from netbox.ui import actions, layout
+from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url
 from netbox.ui.panels import (
     CommentsPanel,
     ObjectsTablePanel,
@@ -39,6 +40,12 @@ class WirelessLANGroupListView(generic.ObjectListView):
 class WirelessLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = WirelessLANGroup.objects.all()
     layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb(
+                lambda o: o.get_ancestors(),
+                url=filtered_list_url('wireless:wirelesslangroup_list', 'parent_id'),
+            ),
+        ],
         left_panels=[
             panels.WirelessLANGroupPanel(),
             TagsPanel(),