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

This commit is contained in:
Jeremy Stretch 2026-07-02 11:46:01 -04:00 committed by GitHub
parent 9ed112b89d
commit 48ce5e7e2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 772 additions and 420 deletions

View File

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

View File

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

View File

@ -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(),

View File

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

View File

@ -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(),

View File

@ -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(),
],

View File

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

View File

@ -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__')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),

View File

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

View File

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

View File

@ -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(),

View File

@ -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(),