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('