diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 4f6e2f25f..91162f08a 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -126,3 +126,13 @@ VERSION = 'v3.3.2-dev' ``` Commit this change with the comment "PRVB" (for _post-release version bump_) and push the commit upstream. + +### Update the Public Documentation + +After a release has been published, the public NetBox documentation needs to be updated. This is accomplished by running two actions on the [netboxlabs-docs](https://github.com/netboxlabs/netboxlabs-docs) repository. + +First, run the `build-site` action, by navigating to Actions > build-site > Run workflow. This process compiles the documentation along with an overlay for integration with the documentation portal at . The job should take about two minutes. + +Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag. + +Finally, verify that the documentation at has been updated. diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index def9a3640..b10b83b23 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -7,7 +7,7 @@ from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.query import count_related -from utilities.views import register_model_view +from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -26,17 +26,12 @@ class ProviderListView(generic.ObjectListView): @register_model_view(Provider) -class ProviderView(generic.ObjectView): +class ProviderView(GetRelatedModelsMixin, generic.ObjectView): queryset = Provider.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (ProviderAccount.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'), - (Circuit.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -92,16 +87,12 @@ class ProviderAccountListView(generic.ObjectListView): @register_model_view(ProviderAccount) -class ProviderAccountView(generic.ObjectView): +class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView): queryset = ProviderAccount.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Circuit.objects.restrict(request.user, 'view').filter(provider_account=instance), 'provider_account_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -156,19 +147,21 @@ class ProviderNetworkListView(generic.ObjectListView): @register_model_view(ProviderNetwork) -class ProviderNetworkView(generic.ObjectView): +class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView): queryset = ProviderNetwork.objects.all() def get_extra_context(self, request, instance): - related_models = ( - ( - Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), - 'provider_network_id', - ), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + instance, + extra=( + ( + Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), + 'provider_network_id', + ), + ), + ), } @@ -215,16 +208,12 @@ class CircuitTypeListView(generic.ObjectListView): @register_model_view(CircuitType) -class CircuitTypeView(generic.ObjectView): +class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = CircuitType.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Circuit.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/core/views.py b/netbox/core/views.py index ded49c0b8..e454f109e 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -32,7 +32,7 @@ from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm from utilities.htmx import htmx_partial from utilities.query import count_related -from utilities.views import ContentTypePermissionRequiredMixin, register_model_view +from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -51,16 +51,12 @@ class DataSourceListView(generic.ObjectListView): @register_model_view(DataSource) -class DataSourceView(generic.ObjectView): +class DataSourceView(GetRelatedModelsMixin, generic.ObjectView): queryset = DataSource.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 670995231..3b8c862a7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -17,7 +17,7 @@ from jinja2.exceptions import TemplateError from circuits.models import Circuit, CircuitTermination from extras.views import ObjectConfigContextView -from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup +from ipam.models import ASN, IPAddress, VLANGroup from ipam.tables import InterfaceVLANTable from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic @@ -27,7 +27,9 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model from utilities.query import count_related from utilities.query_functions import CollateAsChar -from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view +from utilities.views import ( + GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view +) from virtualization.filtersets import VirtualMachineFilterSet from virtualization.models import VirtualMachine from virtualization.tables import VirtualMachineTable @@ -226,19 +228,21 @@ class RegionListView(generic.ObjectListView): @register_model_view(Region) -class RegionView(generic.ObjectView): +class RegionView(GetRelatedModelsMixin, generic.ObjectView): queryset = Region.objects.all() def get_extra_context(self, request, instance): regions = instance.get_descendants(include_self=True) - related_models = ( - (Site.objects.restrict(request.user, 'view').filter(region__in=regions), 'region_id'), - (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), - (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), - ) return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + regions, + extra=( + (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), + (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), + ), + ), } @@ -306,19 +310,21 @@ class SiteGroupListView(generic.ObjectListView): @register_model_view(SiteGroup) -class SiteGroupView(generic.ObjectView): +class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = SiteGroup.objects.all() def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) - related_models = ( - (Site.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), - (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), - (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), - ) return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + groups, + extra=( + (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), + (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), + ), + ), } @@ -380,31 +386,25 @@ class SiteListView(generic.ObjectListView): @register_model_view(Site) -class SiteView(generic.ObjectView): +class SiteView(GetRelatedModelsMixin, generic.ObjectView): queryset = Site.objects.prefetch_related('tenant__group') def get_extra_context(self, request, instance): - related_models = ( - # DCIM - (Location.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), - (Rack.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), - (Device.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), - # Virtualization - (VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance), 'site_id'), - # IPAM - (Prefix.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), - (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'), - (VLANGroup.objects.restrict(request.user, 'view').filter( - scope_type=ContentType.objects.get_for_model(Site), - scope_id=instance.pk - ), 'site'), - (VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), - # Circuits - (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + instance, + [CableTermination, CircuitTermination], + ( + (VLANGroup.objects.restrict(request.user, 'view').filter( + scope_type=ContentType.objects.get_for_model(Site), + scope_id=instance.pk + ), 'site'), + (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'), + (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), + 'site_id'), + ), + ), } @@ -466,18 +466,13 @@ class LocationListView(generic.ObjectListView): @register_model_view(Location) -class LocationView(generic.ObjectView): +class LocationView(GetRelatedModelsMixin, generic.ObjectView): queryset = Location.objects.all() def get_extra_context(self, request, instance): locations = instance.get_descendants(include_self=True) - related_models = ( - (Rack.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'), - (Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, locations, [CableTermination]), } @@ -541,16 +536,12 @@ class RackRoleListView(generic.ObjectListView): @register_model_view(RackRole) -class RackRoleView(generic.ObjectView): +class RackRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackRole.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Rack.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -655,15 +646,10 @@ class RackElevationListView(generic.ObjectListView): @register_model_view(Rack) -class RackView(generic.ObjectView): +class RackView(GetRelatedModelsMixin, generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') def get_extra_context(self, request, instance): - related_models = ( - (Device.objects.restrict(request.user, 'view').filter(rack=instance), 'rack_id'), - (PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'), - ) - peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) if instance.location: @@ -679,7 +665,7 @@ class RackView(generic.ObjectView): ]) return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance, [CableTermination]), 'next_rack': next_rack, 'prev_rack': prev_rack, 'svg_extra': svg_extra, @@ -838,19 +824,12 @@ class ManufacturerListView(generic.ObjectListView): @register_model_view(Manufacturer) -class ManufacturerView(generic.ObjectView): +class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView): queryset = Manufacturer.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (DeviceType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), - (ModuleType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), - (InventoryItem.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), - (Platform.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance, [InventoryItemTemplate]), } @@ -912,16 +891,16 @@ class DeviceTypeListView(generic.ObjectListView): @register_model_view(DeviceType) -class DeviceTypeView(generic.ObjectView): +class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = DeviceType.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Device.objects.restrict(request.user).filter(device_type=instance), 'device_type_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance, omit=[ + ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, + InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, + RearPortTemplate, + ]), } @@ -1151,16 +1130,16 @@ class ModuleTypeListView(generic.ObjectListView): @register_model_view(ModuleType) -class ModuleTypeView(generic.ObjectView): +class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = ModuleType.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Module.objects.restrict(request.user).filter(module_type=instance), 'module_type_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance, omit=[ + ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, + InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, + RearPortTemplate, + ]), } @@ -1711,17 +1690,12 @@ class DeviceRoleListView(generic.ObjectListView): @register_model_view(DeviceRole) -class DeviceRoleView(generic.ObjectView): +class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = DeviceRole.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Device.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - (VirtualMachine.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -1775,17 +1749,12 @@ class PlatformListView(generic.ObjectListView): @register_model_view(Platform) -class PlatformView(generic.ObjectView): +class PlatformView(GetRelatedModelsMixin, generic.ObjectView): queryset = Platform.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Device.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'), - (VirtualMachine.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -2157,22 +2126,12 @@ class ModuleListView(generic.ObjectListView): @register_model_view(Module) -class ModuleView(generic.ObjectView): +class ModuleView(GetRelatedModelsMixin, generic.ObjectView): queryset = Module.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Interface.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (ConsolePort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (ConsoleServerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (PowerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (PowerOutlet.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (FrontPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - (RearPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -3552,16 +3511,12 @@ class PowerPanelListView(generic.ObjectListView): @register_model_view(PowerPanel) -class PowerPanelView(generic.ObjectView): +class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView): queryset = PowerPanel.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (PowerFeed.objects.restrict(request.user).filter(power_panel=instance), 'power_panel_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -3665,16 +3620,18 @@ class VirtualDeviceContextListView(generic.ObjectListView): @register_model_view(VirtualDeviceContext) -class VirtualDeviceContextView(generic.ObjectView): +class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView): queryset = VirtualDeviceContext.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + instance, + extra=( + (Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'), + ), + ), } diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index f94c3c6d7..12c86c533 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -12,7 +12,7 @@ from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.query import count_related from utilities.tables import get_table_ordering -from utilities.views import ViewTab, register_model_view +from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface from . import filtersets, forms, tables @@ -34,15 +34,10 @@ class VRFListView(generic.ObjectListView): @register_model_view(VRF) -class VRFView(generic.ObjectView): +class VRFView(GetRelatedModelsMixin, generic.ObjectView): queryset = VRF.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Prefix.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'), - (IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'), - ) - import_targets_table = tables.RouteTargetTable( instance.import_targets.all(), orderable=False @@ -53,7 +48,7 @@ class VRFView(generic.ObjectView): ) return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]), 'import_targets_table': import_targets_table, 'export_targets_table': export_targets_table, } @@ -147,16 +142,12 @@ class RIRListView(generic.ObjectListView): @register_model_view(RIR) -class RIRView(generic.ObjectView): +class RIRView(GetRelatedModelsMixin, generic.ObjectView): queryset = RIR.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Aggregate.objects.restrict(request.user, 'view').filter(rir=instance), 'rir_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -273,17 +264,19 @@ class ASNListView(generic.ObjectListView): @register_model_view(ASN) -class ASNView(generic.ObjectView): +class ASNView(GetRelatedModelsMixin, generic.ObjectView): queryset = ASN.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'), - (Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models( + request, + instance, + extra=( + (Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'), + (Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'), + ), + ), } @@ -427,18 +420,12 @@ class RoleListView(generic.ObjectListView): @register_model_view(Role) -class RoleView(generic.ObjectView): +class RoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = Role.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Prefix.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - (IPRange.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - (VLAN.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -926,16 +913,12 @@ class VLANGroupListView(generic.ObjectListView): @register_model_view(VLANGroup) -class VLANGroupView(generic.ObjectView): +class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') def get_extra_context(self, request, instance): - related_models = ( - (VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index c37bb1b0d..cfe6c9be6 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -1,3 +1,4 @@ +import zoneinfo from dataclasses import dataclass from typing import Optional from urllib.parse import quote @@ -83,6 +84,8 @@ class DateTimeColumn(tables.Column): def render(self, value): if value: + current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE) + value = value.astimezone(current_tz) return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}" def value(self, value): diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 03dcc94bd..06fbcc575 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -4,8 +4,7 @@ from django.utils.translation import gettext as _ from netbox.views import generic from utilities.query import count_related -from utilities.relations import get_related_models -from utilities.views import register_model_view, ViewTab +from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view from . import filtersets, forms, tables from .models import * @@ -56,17 +55,14 @@ class TenantGroupListView(generic.ObjectListView): @register_model_view(TenantGroup) -class TenantGroupView(generic.ObjectView): +class TenantGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = TenantGroup.objects.all() def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) - related_models = ( - (Tenant.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), - ) return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, groups), } @@ -123,17 +119,12 @@ class TenantListView(generic.ObjectListView): @register_model_view(Tenant) -class TenantView(generic.ObjectView): +class TenantView(GetRelatedModelsMixin, generic.ObjectView): queryset = Tenant.objects.all() def get_extra_context(self, request, instance): - related_models = [ - (model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id') - for model, field in get_related_models(Tenant) - ] - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -189,17 +180,14 @@ class ContactGroupListView(generic.ObjectListView): @register_model_view(ContactGroup) -class ContactGroupView(generic.ObjectView): +class ContactGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = ContactGroup.objects.all() def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) - related_models = ( - (Contact.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), - ) return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, groups), } @@ -256,16 +244,12 @@ class ContactRoleListView(generic.ObjectListView): @register_model_view(ContactRole) -class ContactRoleView(generic.ObjectView): +class ContactRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = ContactRole.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (ContactAssignment.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index 037f586f6..b8b3cf9a3 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-06-08 05:02+0000\n" +"POT-Creation-Date: 2024-06-12 05:01+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -29,7 +29,7 @@ msgid "Write Enabled" msgstr "" #: netbox/account/tables.py:35 netbox/core/tables/jobs.py:29 -#: netbox/core/tables/tasks.py:79 netbox/extras/choices.py:138 +#: netbox/core/tables/tasks.py:79 netbox/extras/choices.py:142 #: netbox/extras/tables/tables.py:499 netbox/templates/account/token.html:43 #: netbox/templates/core/configrevision.html:26 #: netbox/templates/core/configrevision_restore.html:12 @@ -1400,7 +1400,7 @@ msgid "Syncing" msgstr "" #: netbox/core/choices.py:21 netbox/core/choices.py:57 -#: netbox/core/tables/jobs.py:41 netbox/extras/choices.py:224 +#: netbox/core/tables/jobs.py:41 netbox/extras/choices.py:228 #: netbox/templates/core/job.html:68 msgid "Completed" msgstr "" @@ -1408,7 +1408,7 @@ msgstr "" #: netbox/core/choices.py:22 netbox/core/choices.py:59 #: netbox/core/constants.py:20 netbox/core/tables/tasks.py:34 #: netbox/dcim/choices.py:176 netbox/dcim/choices.py:222 -#: netbox/dcim/choices.py:1536 netbox/extras/choices.py:226 +#: netbox/dcim/choices.py:1536 netbox/extras/choices.py:230 #: netbox/virtualization/choices.py:47 msgid "Failed" msgstr "" @@ -1426,21 +1426,21 @@ msgstr "" msgid "Reports" msgstr "" -#: netbox/core/choices.py:54 netbox/extras/choices.py:221 +#: netbox/core/choices.py:54 netbox/extras/choices.py:225 msgid "Pending" msgstr "" #: netbox/core/choices.py:55 netbox/core/constants.py:23 #: netbox/core/tables/jobs.py:32 netbox/core/tables/tasks.py:38 -#: netbox/extras/choices.py:222 netbox/templates/core/job.html:55 +#: netbox/extras/choices.py:226 netbox/templates/core/job.html:55 msgid "Scheduled" msgstr "" -#: netbox/core/choices.py:56 netbox/extras/choices.py:223 +#: netbox/core/choices.py:56 netbox/extras/choices.py:227 msgid "Running" msgstr "" -#: netbox/core/choices.py:58 netbox/extras/choices.py:225 +#: netbox/core/choices.py:58 netbox/extras/choices.py:229 msgid "Errored" msgstr "" @@ -6483,71 +6483,79 @@ msgstr "" msgid "Link" msgstr "" -#: netbox/extras/choices.py:122 +#: netbox/extras/choices.py:124 msgid "Newest" msgstr "" -#: netbox/extras/choices.py:123 +#: netbox/extras/choices.py:125 msgid "Oldest" msgstr "" -#: netbox/extras/choices.py:139 netbox/templates/generic/object.html:61 +#: netbox/extras/choices.py:126 +msgid "Alphabetical (A-Z)" +msgstr "" + +#: netbox/extras/choices.py:127 +msgid "Alphabetical (Z-A)" +msgstr "" + +#: netbox/extras/choices.py:143 netbox/templates/generic/object.html:61 msgid "Updated" msgstr "" -#: netbox/extras/choices.py:140 +#: netbox/extras/choices.py:144 msgid "Deleted" msgstr "" -#: netbox/extras/choices.py:157 netbox/extras/choices.py:181 +#: netbox/extras/choices.py:161 netbox/extras/choices.py:185 msgid "Info" msgstr "" -#: netbox/extras/choices.py:158 netbox/extras/choices.py:180 +#: netbox/extras/choices.py:162 netbox/extras/choices.py:184 msgid "Success" msgstr "" -#: netbox/extras/choices.py:159 netbox/extras/choices.py:182 +#: netbox/extras/choices.py:163 netbox/extras/choices.py:186 msgid "Warning" msgstr "" -#: netbox/extras/choices.py:160 +#: netbox/extras/choices.py:164 msgid "Danger" msgstr "" -#: netbox/extras/choices.py:178 +#: netbox/extras/choices.py:182 msgid "Debug" msgstr "" -#: netbox/extras/choices.py:179 netbox/netbox/choices.py:104 +#: netbox/extras/choices.py:183 netbox/netbox/choices.py:104 msgid "Default" msgstr "" -#: netbox/extras/choices.py:183 +#: netbox/extras/choices.py:187 msgid "Failure" msgstr "" -#: netbox/extras/choices.py:199 +#: netbox/extras/choices.py:203 msgid "Hourly" msgstr "" -#: netbox/extras/choices.py:200 +#: netbox/extras/choices.py:204 msgid "12 hours" msgstr "" -#: netbox/extras/choices.py:201 +#: netbox/extras/choices.py:205 msgid "Daily" msgstr "" -#: netbox/extras/choices.py:202 +#: netbox/extras/choices.py:206 msgid "Weekly" msgstr "" -#: netbox/extras/choices.py:203 +#: netbox/extras/choices.py:207 msgid "30 days" msgstr "" -#: netbox/extras/choices.py:268 netbox/extras/tables/tables.py:296 +#: netbox/extras/choices.py:272 netbox/extras/tables/tables.py:296 #: netbox/templates/dcim/virtualchassis_edit.html:107 #: netbox/templates/extras/eventrule.html:40 #: netbox/templates/generic/bulk_add_component.html:68 @@ -6557,12 +6565,12 @@ msgstr "" msgid "Create" msgstr "" -#: netbox/extras/choices.py:269 netbox/extras/tables/tables.py:299 +#: netbox/extras/choices.py:273 netbox/extras/tables/tables.py:299 #: netbox/templates/extras/eventrule.html:44 msgid "Update" msgstr "" -#: netbox/extras/choices.py:270 netbox/extras/tables/tables.py:302 +#: netbox/extras/choices.py:274 netbox/extras/tables/tables.py:302 #: netbox/templates/circuits/inc/circuit_termination.html:23 #: netbox/templates/dcim/inc/panels/inventory_items.html:37 #: netbox/templates/dcim/moduletype/component_templates.html:23 @@ -6579,77 +6587,77 @@ msgstr "" msgid "Delete" msgstr "" -#: netbox/extras/choices.py:294 netbox/netbox/choices.py:57 +#: netbox/extras/choices.py:298 netbox/netbox/choices.py:57 #: netbox/netbox/choices.py:105 msgid "Blue" msgstr "" -#: netbox/extras/choices.py:295 netbox/netbox/choices.py:56 +#: netbox/extras/choices.py:299 netbox/netbox/choices.py:56 #: netbox/netbox/choices.py:106 msgid "Indigo" msgstr "" -#: netbox/extras/choices.py:296 netbox/netbox/choices.py:54 +#: netbox/extras/choices.py:300 netbox/netbox/choices.py:54 #: netbox/netbox/choices.py:107 msgid "Purple" msgstr "" -#: netbox/extras/choices.py:297 netbox/netbox/choices.py:51 +#: netbox/extras/choices.py:301 netbox/netbox/choices.py:51 #: netbox/netbox/choices.py:108 msgid "Pink" msgstr "" -#: netbox/extras/choices.py:298 netbox/netbox/choices.py:50 +#: netbox/extras/choices.py:302 netbox/netbox/choices.py:50 #: netbox/netbox/choices.py:109 msgid "Red" msgstr "" -#: netbox/extras/choices.py:299 netbox/netbox/choices.py:68 +#: netbox/extras/choices.py:303 netbox/netbox/choices.py:68 #: netbox/netbox/choices.py:110 msgid "Orange" msgstr "" -#: netbox/extras/choices.py:300 netbox/netbox/choices.py:66 +#: netbox/extras/choices.py:304 netbox/netbox/choices.py:66 #: netbox/netbox/choices.py:111 msgid "Yellow" msgstr "" -#: netbox/extras/choices.py:301 netbox/netbox/choices.py:63 +#: netbox/extras/choices.py:305 netbox/netbox/choices.py:63 #: netbox/netbox/choices.py:112 msgid "Green" msgstr "" -#: netbox/extras/choices.py:302 netbox/netbox/choices.py:60 +#: netbox/extras/choices.py:306 netbox/netbox/choices.py:60 #: netbox/netbox/choices.py:113 msgid "Teal" msgstr "" -#: netbox/extras/choices.py:303 netbox/netbox/choices.py:59 +#: netbox/extras/choices.py:307 netbox/netbox/choices.py:59 #: netbox/netbox/choices.py:114 msgid "Cyan" msgstr "" -#: netbox/extras/choices.py:304 netbox/netbox/choices.py:115 +#: netbox/extras/choices.py:308 netbox/netbox/choices.py:115 msgid "Gray" msgstr "" -#: netbox/extras/choices.py:305 netbox/netbox/choices.py:74 +#: netbox/extras/choices.py:309 netbox/netbox/choices.py:74 #: netbox/netbox/choices.py:116 msgid "Black" msgstr "" -#: netbox/extras/choices.py:306 netbox/netbox/choices.py:75 +#: netbox/extras/choices.py:310 netbox/netbox/choices.py:75 #: netbox/netbox/choices.py:117 msgid "White" msgstr "" -#: netbox/extras/choices.py:320 netbox/extras/forms/model_forms.py:242 +#: netbox/extras/choices.py:324 netbox/extras/forms/model_forms.py:242 #: netbox/extras/forms/model_forms.py:324 #: netbox/templates/extras/webhook.html:10 msgid "Webhook" msgstr "" -#: netbox/extras/choices.py:321 netbox/extras/forms/model_forms.py:312 +#: netbox/extras/choices.py:325 netbox/extras/forms/model_forms.py:312 #: netbox/templates/extras/script/base.html:29 msgid "Script" msgstr "" diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 4bca48dbd..75c48b01f 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,3 +1,5 @@ +from typing import Iterable + from django.contrib.auth.mixins import AccessMixin from django.core.exceptions import ImproperlyConfigured from django.urls import reverse @@ -6,10 +8,12 @@ from django.utils.translation import gettext_lazy as _ from netbox.plugins import PluginConfig from netbox.registry import registry +from utilities.relations import get_related_models from .permissions import resolve_permission __all__ = ( 'ContentTypePermissionRequiredMixin', + 'GetRelatedModelsMixin', 'GetReturnURLMixin', 'ObjectPermissionRequiredMixin', 'ViewTab', @@ -142,6 +146,46 @@ class GetReturnURLMixin: return reverse('home') +class GetRelatedModelsMixin: + """ + Provides logic for collecting all related models for the currently viewed model. + """ + + def get_related_models(self, request, instance, omit=[], extra=[]): + """ + Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical. + + Args: + request: Current request being processed. + instance: The instance related models should be looked up for. A list of instances can be passed to match + related objects in this list (e.g. to find sites of a region including child regions). + omit: Remove relationships to these models from the result. Needs to be passed, if related models don't + provide a `_list` view. + extra: Add extra models to the list of automatically determined related models. Can be used to add indirect + relationships. + """ + model = self.queryset.model + related = filter( + lambda m: m[0] is not model and m[0] not in omit, + get_related_models(model, False) + ) + + related_models = [ + ( + model.objects.restrict(request.user, 'view').filter(**( + {f'{field}__in': instance} + if isinstance(instance, Iterable) + else {field: instance} + )), + f'{field}_id' + ) + for model, field in related + ] + related_models.extend(extra) + + return sorted(related_models, key=lambda x: x[0].model._meta.verbose_name.lower()) + + class ViewTab: """ ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 56d8feb26..c143fff85 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -20,7 +20,7 @@ from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.query import count_related from utilities.query_functions import CollateAsChar -from utilities.views import ViewTab, register_model_view +from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view from . import filtersets, forms, tables from .models import * @@ -39,16 +39,12 @@ class ClusterTypeListView(generic.ObjectListView): @register_model_view(ClusterType) -class ClusterTypeView(generic.ObjectView): +class ClusterTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = ClusterType.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Cluster.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } @@ -99,16 +95,12 @@ class ClusterGroupListView(generic.ObjectListView): @register_model_view(ClusterGroup) -class ClusterGroupView(generic.ObjectView): +class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = ClusterGroup.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Cluster.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index b2dcf4038..ac8ce3667 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -2,7 +2,7 @@ from ipam.tables import RouteTargetTable from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.query import count_related -from utilities.views import register_model_view +from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -21,16 +21,12 @@ class TunnelGroupListView(generic.ObjectListView): @register_model_view(TunnelGroup) -class TunnelGroupView(generic.ObjectView): +class TunnelGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = TunnelGroup.objects.all() def get_extra_context(self, request, instance): - related_models = ( - (Tunnel.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'), - ) - return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, instance), } diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 891bb6f84..5063f0fee 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,7 +1,7 @@ from dcim.models import Interface from netbox.views import generic from utilities.query import count_related -from utilities.views import register_model_view +from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -24,17 +24,14 @@ class WirelessLANGroupListView(generic.ObjectListView): @register_model_view(WirelessLANGroup) -class WirelessLANGroupView(generic.ObjectView): +class WirelessLANGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = WirelessLANGroup.objects.all() def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) - related_models = ( - (WirelessLAN.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), - ) return { - 'related_models': related_models, + 'related_models': self.get_related_models(request, groups), }