diff --git a/.gitignore b/.gitignore index 88faab27c..e04e44a30 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ netbox.pid .idea .coverage .vscode +.python-version 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/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md index ae0578690..9ca245e04 100644 --- a/docs/release-notes/version-4.0.md +++ b/docs/release-notes/version-4.0.md @@ -2,6 +2,26 @@ ## v4.0.6 (FUTURE) +### Enhancements + +* [#15348](https://github.com/netbox-community/netbox/issues/15348) - Show saved filters alongside quick search on object list views +* [#15794](https://github.com/netbox-community/netbox/issues/15794) - Dynamically populate related objects in UI views +* [#16256](https://github.com/netbox-community/netbox/issues/16256) - Enable alphabetical ordering of bookmarks on dashboard + +### Bug Fixes + +* [#13925](https://github.com/netbox-community/netbox/issues/13925) - Fix support for "zulu" (UTC) timestamps for custom fields +* [#14829](https://github.com/netbox-community/netbox/issues/14829) - Fix support for simple conditions (without AND/OR) in event rules +* [#16143](https://github.com/netbox-community/netbox/issues/16143) - Display timestamps in tables in the configured timezone +* [#16416](https://github.com/netbox-community/netbox/issues/16416) - Retain dark/light mode toggle on mobile view +* [#16444](https://github.com/netbox-community/netbox/issues/16444) - Disable ordering circuits list by A/Z termination +* [#16450](https://github.com/netbox-community/netbox/issues/16450) - Searching for rack unit in form dropdown should be case-insensitive +* [#16452](https://github.com/netbox-community/netbox/issues/16452) - Fix sizing of buttons within object attribute panels +* [#16454](https://github.com/netbox-community/netbox/issues/16454) - Address DNS lookup bug in `django-debug-toolbar +* [#16460](https://github.com/netbox-community/netbox/issues/16460) - Omit spaces from telephone number URLs +* [#16512](https://github.com/netbox-community/netbox/issues/16512) - Restore a user's preferred language (if any) on login +* [#16542](https://github.com/netbox-community/netbox/issues/16542) - Fix bulk form operations when HTMX is enabled + --- ## v4.0.5 (2024-06-06) diff --git a/netbox/account/views.py b/netbox/account/views.py index 40ce78039..feb85fdfe 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -104,10 +104,16 @@ class LoginView(View): # Ensure the user has a UserConfig defined. (This should normally be handled by # create_userconfig() on user creation.) if not hasattr(request.user, 'config'): - config = get_config() - UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save() + request.user.config = get_config() + UserConfig(user=request.user, data=request.user.config.DEFAULT_USER_PREFERENCES).save() - return self.redirect_to_next(request, logger) + response = self.redirect_to_next(request, logger) + + # Set the user's preferred language (if any) + if language := request.user.config.get('locale.language'): + response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language) + + return response else: logger.debug(f"Login form validation failed for username: {form['username'].value()}") @@ -145,9 +151,10 @@ class LogoutView(View): logger.info(f"User {username} has logged out") messages.info(request, "You have logged out.") - # Delete session key cookie (if set) upon logout + # Delete session key & language cookies (if set) upon logout response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL)) response.delete_cookie('session_key') + response.delete_cookie(settings.LANGUAGE_COOKIE_NAME) return response diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 5d650df61..e1b99ff42 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -63,10 +63,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): status = columns.ChoiceFieldColumn() termination_a = tables.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, + orderable=False, verbose_name=_('Side A') ) termination_z = tables.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, + orderable=False, verbose_name=_('Side Z') ) commit_rate = CommitRateColumn( 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/api/views.py b/netbox/dcim/api/views.py index d6ddd466b..be7a9c306 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -219,9 +219,9 @@ class RackViewSet(NetBoxModelViewSet): ) # Enable filtering rack units by ID - q = data['q'] - if q: - elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name'])] + if q := data['q']: + q = q.lower() + elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name']).lower()] page = self.paginate_queryset(elevation) if page is not None: 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/extras/choices.py b/netbox/extras/choices.py index 2c9d5836a..12e10f553 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -117,10 +117,14 @@ class BookmarkOrderingChoices(ChoiceSet): ORDERING_NEWEST = '-created' ORDERING_OLDEST = 'created' + ORDERING_ALPHABETICAL_AZ = 'name' + ORDERING_ALPHABETICAL_ZA = '-name' CHOICES = ( (ORDERING_NEWEST, _('Newest')), (ORDERING_OLDEST, _('Oldest')), + (ORDERING_ALPHABETICAL_AZ, _('Alphabetical (A-Z)')), + (ORDERING_ALPHABETICAL_ZA, _('Alphabetical (Z-A)')), ) # diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index add81a318..c4710468b 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -381,11 +381,17 @@ class BookmarksWidget(DashboardWidget): if request.user.is_anonymous: bookmarks = list() else: - bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by']) + user_bookmarks = Bookmark.objects.filter(user=request.user) + if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ: + bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower()) + elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA: + bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True) + else: + bookmarks = user_bookmarks.order_by(self.config['order_by']) if object_types := self.config.get('object_types'): models = get_models_from_content_types(object_types) - conent_types = ObjectType.objects.get_for_models(*models).values() - bookmarks = bookmarks.filter(object_type__in=conent_types) + content_types = ObjectType.objects.get_for_models(*models).values() + bookmarks = bookmarks.filter(object_type__in=content_types) if max_items := self.config.get('max_items'): bookmarks = bookmarks[:max_items] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 974affb2e..240998146 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -660,6 +660,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # Validate date & time elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: if type(value) is not datetime: + # Work around UTC issue for Python < 3.11; see + # https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat + if type(value) is str and value.endswith('Z'): + value = f'{value[:-1]}+00:00' try: datetime.fromisoformat(value) except ValueError: 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/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 0696d2e82..36ed4defc 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 59f703d4b..f4d0311ab 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 00b92809a..c5cd1a402 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/forms/savedFiltersSelect.ts b/netbox/project-static/src/forms/savedFiltersSelect.ts new file mode 100644 index 000000000..1d06a8d0b --- /dev/null +++ b/netbox/project-static/src/forms/savedFiltersSelect.ts @@ -0,0 +1,30 @@ +import { isTruthy } from '../util'; + +/** + * Handle saved filter change event. + * + * @param event "change" event for the saved filter select + */ +function handleSavedFilterChange(event: Event): void { + const savedFilter = event.currentTarget as HTMLSelectElement; + let baseUrl = savedFilter.baseURI.split('?')[0]; + const preFilter = '?'; + + const selectedOptions = Array.from(savedFilter.options) + .filter(option => option.selected) + .map(option => `filter_id=${option.value}`) + .join('&'); + + baseUrl += `${preFilter}${selectedOptions}`; + document.location.href = baseUrl; +} + +export function initSavedFilterSelect(): void { + const divResults = document.getElementById('results'); + if (isTruthy(divResults)) { + const savedFilterSelect = document.getElementById('id_filter_id'); + if (isTruthy(savedFilterSelect)) { + savedFilterSelect.addEventListener('change', handleSavedFilterChange); + } + } +} diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts index 59faab222..ce0aad93f 100644 --- a/netbox/project-static/src/netbox.ts +++ b/netbox/project-static/src/netbox.ts @@ -13,6 +13,7 @@ import { initSideNav } from './sidenav'; import { initDashboard } from './dashboard'; import { initRackElevation } from './racks'; import { initHtmx } from './htmx'; +import { initSavedFilterSelect } from './forms/savedFiltersSelect'; function initDocument(): void { for (const init of [ @@ -31,6 +32,7 @@ function initDocument(): void { initDashboard, initRackElevation, initHtmx, + initSavedFilterSelect, ]) { init(); } diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index b04b85fc9..af2905312 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -7,6 +7,7 @@ // Overrides of external libraries @import 'overrides/bootstrap'; @import 'overrides/tabler'; +@import 'overrides/tomselect'; // Transitional styling to ease migration of templates from NetBox v3.x @import 'transitional/badges'; diff --git a/netbox/project-static/styles/overrides/_tomselect.scss b/netbox/project-static/styles/overrides/_tomselect.scss new file mode 100644 index 000000000..29aa9d361 --- /dev/null +++ b/netbox/project-static/styles/overrides/_tomselect.scss @@ -0,0 +1,8 @@ +.ts-wrapper.multi { + .ts-control { + padding: 7px 7px 3px 7px; + div { + margin: 0 4px 4px 0; + } + } +} diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index d53591cb4..9ba6fded3 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -35,6 +35,7 @@ Blocks: {# User menu (mobile view) #} @@ -52,14 +53,7 @@ Blocks: