diff --git a/docs/plugins/development/ui-components.md b/docs/plugins/development/ui-components.md index f148443e3..7a8d8cf80 100644 --- a/docs/plugins/development/ui-components.md +++ b/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. diff --git a/netbox/account/views.py b/netbox/account/views.py index 1c3d8cade..e701b7407 100644 --- a/netbox/account/views.py +++ b/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, diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 85912e00b..3cdd7f6f3 100644 --- a/netbox/circuits/views.py +++ b/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(), diff --git a/netbox/core/views.py b/netbox/core/views.py index 78059ce2d..bb9a9d1dd 100644 --- a/netbox/core/views.py +++ b/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): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bd2f43a86..114e3e306 100644 --- a/netbox/dcim/views.py +++ b/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(), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 40ca54159..e4a54b943 100644 --- a/netbox/ipam/views.py +++ b/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(), ], diff --git a/netbox/netbox/tests/test_ui.py b/netbox/netbox/tests/test_ui.py index 9ecd91ef0..77226226d 100644 --- a/netbox/netbox/tests/test_ui.py +++ b/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('', 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('', 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('', 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('', html) + self.assertInHTML('', 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('Plain', 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('', 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('', 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('My API Tokens', 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('Units 1-5', 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('', 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('', 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'', 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'', html) + self.assertInHTML( + f'', html + ) + self.assertInHTML( + f'', 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'', html + ) diff --git a/netbox/netbox/ui/breadcrumbs.py b/netbox/netbox/ui/breadcrumbs.py new file mode 100644 index 000000000..878864f57 --- /dev/null +++ b/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=`. + """ + 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': })`. + """ + 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__') diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py index 375417288..4df07d216 100644 --- a/netbox/netbox/ui/layout.py +++ b/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) diff --git a/netbox/templates/account/token.html b/netbox/templates/account/token.html deleted file mode 100644 index 6fcf9e359..000000000 --- a/netbox/templates/account/token.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'users/token.html' %} -{% load i18n %} - -{% block breadcrumbs %} - -{% endblock breadcrumbs %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html deleted file mode 100644 index 18b00b98d..000000000 --- a/netbox/templates/circuits/circuit.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends 'generic/object.html' %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} diff --git a/netbox/templates/circuits/circuitgroup.html b/netbox/templates/circuits/circuitgroup.html index 16ec51510..24092c817 100644 --- a/netbox/templates/circuits/circuitgroup.html +++ b/netbox/templates/circuits/circuitgroup.html @@ -1,11 +1,6 @@ {% extends 'generic/object.html' %} {% load i18n %} -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} - {% block extra_controls %} {% if perms.circuit.add_circuitgroupassignment %} diff --git a/netbox/templates/circuits/circuitgroupassignment.html b/netbox/templates/circuits/circuitgroupassignment.html deleted file mode 100644 index a80615afd..000000000 --- a/netbox/templates/circuits/circuitgroupassignment.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'generic/object.html' %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} diff --git a/netbox/templates/circuits/circuittermination.html b/netbox/templates/circuits/circuittermination.html deleted file mode 100644 index 4a694c8f1..000000000 --- a/netbox/templates/circuits/circuittermination.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load i18n %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} diff --git a/netbox/templates/circuits/provideraccount.html b/netbox/templates/circuits/provideraccount.html deleted file mode 100644 index 17b8519f2..000000000 --- a/netbox/templates/circuits/provideraccount.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends 'generic/object.html' %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html deleted file mode 100644 index 92bad10ca..000000000 --- a/netbox/templates/circuits/providernetwork.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends 'generic/object.html' %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} diff --git a/netbox/templates/circuits/virtualcircuit.html b/netbox/templates/circuits/virtualcircuit.html deleted file mode 100644 index ad65b3342..000000000 --- a/netbox/templates/circuits/virtualcircuit.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'generic/object.html' %} - -{% block breadcrumbs %} - {{ block.super }} - - -{% endblock %} diff --git a/netbox/templates/circuits/virtualcircuittermination.html b/netbox/templates/circuits/virtualcircuittermination.html deleted file mode 100644 index f3b38eb24..000000000 --- a/netbox/templates/circuits/virtualcircuittermination.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends 'generic/object.html' %} -{% load i18n %} - -{% block breadcrumbs %} - {{ block.super }} - - - -{% endblock %} diff --git a/netbox/templates/core/configrevision.html b/netbox/templates/core/configrevision.html index 2bd361a84..7de0b3890 100644 --- a/netbox/templates/core/configrevision.html +++ b/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 %} diff --git a/netbox/templates/core/datafile.html b/netbox/templates/core/datafile.html deleted file mode 100644 index 714ef22f8..000000000 --- a/netbox/templates/core/datafile.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'generic/object.html' %} -{% load i18n %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} diff --git a/netbox/templates/dcim/cablebundle.html b/netbox/templates/dcim/cablebundle.html index cb1237f2a..e29855623 100644 --- a/netbox/templates/dcim/cablebundle.html +++ b/netbox/templates/dcim/cablebundle.html @@ -3,10 +3,6 @@ {% load plugins %} {% load i18n %} -{% block breadcrumbs %} - -{% endblock %} - {% block content %}
diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html deleted file mode 100644 index 313d6aec6..000000000 --- a/netbox/templates/dcim/consoleport.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'generic/object.html' %} -{% load i18n %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html deleted file mode 100644 index 80f0e54a2..000000000 --- a/netbox/templates/dcim/consoleserverport.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'generic/object.html' %} -{% load i18n %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html deleted file mode 100644 index afc3ad506..000000000 --- a/netbox/templates/dcim/devicebay.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'generic/object.html' %} -{% load i18n %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 72fdbd3ed..535c3a88d 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/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 %} - -{% endblock %} - {% block extra_controls %} {% if perms.dcim.add_device %} diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html index 19d3ec33c..cd6a5b9f0 100644 --- a/netbox/templates/dcim/devicetype/base.html +++ b/netbox/templates/dcim/devicetype/base.html @@ -6,11 +6,6 @@ {% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} - {% block extra_controls %} {% if perms.dcim.change_devicetype %}