diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index a707eb6ad..622fbb4b9 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -4,9 +4,10 @@ The `users.UserConfig` model holds individual preferences for each user in the f ## Available Preferences -| Name | Description | -|-------------------------|-------------| -| data_format | Preferred format when rendering raw data (JSON or YAML) | -| pagination.per_page | The number of items to display per page of a paginated table | -| tables.${table}.columns | The ordered list of columns to display when viewing the table | -| ui.colormode | Light or dark mode in the user interface | +| Name | Description | +|--------------------------|---------------------------------------------------------------| +| data_format | Preferred format when rendering raw data (JSON or YAML) | +| pagination.per_page | The number of items to display per page of a paginated table | +| tables.${table}.columns | The ordered list of columns to display when viewing the table | +| tables.${table}.ordering | A list of column names by which the table should be ordered | +| ui.colormode | Light or dark mode in the user interface | diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 31025bb85..a2bc5988e 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -56,6 +56,7 @@ Inventory item templates can be arranged hierarchically within a device type, an ### Enhancements +* [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation * [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables * [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 2f1addab1..97e985dcd 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -5,7 +5,7 @@ from django.shortcuts import get_object_or_404, redirect, render from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .choices import CircuitTerminationSideChoices @@ -35,7 +35,7 @@ class ProviderView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -96,7 +96,7 @@ class ProviderNetworkView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -150,7 +150,7 @@ class CircuitTypeView(generic.ObjectView): def get_extra_context(self, request, instance): circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance) circuits_table = tables.CircuitTable(circuits, exclude=('type',)) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e64124539..a85fc7438 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,7 +20,7 @@ from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from virtualization.models import VirtualMachine @@ -165,7 +165,7 @@ class RegionView(generic.ObjectView): region=instance ) sites_table = tables.SiteTable(sites, exclude=('region',)) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'child_regions_table': child_regions_table, @@ -250,7 +250,7 @@ class SiteGroupView(generic.ObjectView): group=instance ) sites_table = tables.SiteTable(sites, exclude=('group',)) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'child_groups_table': child_groups_table, @@ -422,7 +422,7 @@ class LocationView(generic.ObjectView): cumulative=True ).filter(pk__in=location_ids).exclude(pk=instance.pk) child_locations_table = tables.LocationTable(child_locations) - paginate_table(child_locations_table, request) + configure_table(child_locations_table, request) return { 'rack_count': rack_count, @@ -493,7 +493,7 @@ class RackRoleView(generic.ObjectView): ) racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization')) - paginate_table(racks_table, request) + configure_table(racks_table, request) return { 'racks_table': racks_table, @@ -743,7 +743,7 @@ class ManufacturerView(generic.ObjectView): ) devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',)) - paginate_table(devicetypes_table, request) + configure_table(devicetypes_table, request) return { 'devicetypes_table': devicetypes_table, @@ -1439,7 +1439,7 @@ class DeviceRoleView(generic.ObjectView): device_role=instance ) devices_table = tables.DeviceTable(devices, exclude=('device_role',)) - paginate_table(devices_table, request) + configure_table(devices_table, request) return { 'devices_table': devices_table, @@ -1503,7 +1503,7 @@ class PlatformView(generic.ObjectView): platform=instance ) devices_table = tables.DeviceTable(devices, exclude=('platform',)) - paginate_table(devices_table, request) + configure_table(devices_table, request) return { 'devices_table': devices_table, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 0df4d6905..59f922d82 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -11,7 +11,7 @@ from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.htmx import is_htmx -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filtersets, forms, tables @@ -215,7 +215,7 @@ class TagView(generic.ObjectView): data=tagged_items, orderable=False ) - paginate_table(taggeditem_table, request) + configure_table(taggeditem_table, request) object_types = [ { @@ -451,7 +451,7 @@ class ObjectChangeLogView(View): data=objectchanges, orderable=False ) - paginate_table(objectchanges_table, request) + configure_table(objectchanges_table, request) # Default to using "/.html" as the template, if it exists. Otherwise, # fall back to using base.html. @@ -571,7 +571,7 @@ class ObjectJournalView(View): assigned_object_id=obj.pk ) journalentry_table = tables.ObjectJournalTable(journalentries) - paginate_table(journalentry_table, request) + configure_table(journalentry_table, request) if request.user.has_perm('extras.add_journalentry'): form = forms.JournalEntryForm( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1f20e886f..23d6eb2a7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -8,7 +8,7 @@ from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from dcim.tables import SiteTable from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface @@ -161,7 +161,7 @@ class RIRView(generic.ObjectView): rir=instance ) aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) - paginate_table(aggregates_table, request) + configure_table(aggregates_table, request) return { 'aggregates_table': aggregates_table, @@ -219,7 +219,7 @@ class ASNView(generic.ObjectView): def get_extra_context(self, request, instance): sites = instance.sites.restrict(request.user, 'view') sites_table = SiteTable(sites) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'sites_table': sites_table, @@ -356,7 +356,7 @@ class RoleView(generic.ObjectView): ) prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization')) - paginate_table(prefixes_table, request) + configure_table(prefixes_table, request) return { 'prefixes_table': prefixes_table, @@ -664,7 +664,7 @@ class IPAddressView(generic.ObjectView): vrf=instance.vrf, address__net_contained_or_equal=str(instance.address) ) related_ips_table = tables.IPAddressTable(related_ips, orderable=False) - paginate_table(related_ips_table, request) + configure_table(related_ips_table, request) return { 'parent_prefixes_table': parent_prefixes_table, @@ -800,7 +800,7 @@ class VLANGroupView(generic.ObjectView): vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes')) if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): vlans_table.columns.show('pk') - paginate_table(vlans_table, request) + configure_table(vlans_table, request) # Compile permissions list for rendering the object table permissions = { diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index d8850391b..f5e315801 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -15,7 +15,6 @@ from django.utils.safestring import mark_safe from django.views.generic import View from django_tables2.export import TableExport -from dcim.forms.object_create import ComponentCreateForm from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror @@ -23,7 +22,7 @@ from utilities.exceptions import AbortTransaction, PermissionsViolation from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin @@ -135,7 +134,7 @@ class ObjectChildrenView(ObjectView): # Determine whether to display bulk action checkboxes if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.columns.show('pk') - paginate_table(table, request) + configure_table(table, request) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): @@ -284,7 +283,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): # Render the objects table table = self.get_table(request, permissions) - paginate_table(table, request) + configure_table(table, request) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index b41af62ee..d634292ec 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -6,7 +6,7 @@ from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables @@ -38,7 +38,7 @@ class TenantGroupView(generic.ObjectView): group=instance ) tenants_table = tables.TenantTable(tenants, exclude=('group',)) - paginate_table(tenants_table, request) + configure_table(tenants_table, request) return { 'tenants_table': tenants_table, @@ -184,7 +184,7 @@ class ContactGroupView(generic.ObjectView): group=instance ) contacts_table = tables.ContactTable(contacts, exclude=('group',)) - paginate_table(contacts_table, request) + configure_table(contacts_table, request) return { 'child_groups_table': child_groups_table, @@ -251,7 +251,7 @@ class ContactRoleView(generic.ObjectView): ) contacts_table = tables.ContactAssignmentTable(contact_assignments) contacts_table.columns.hide('role') - paginate_table(contacts_table, request) + configure_table(contacts_table, request) return { 'contacts_table': contacts_table, @@ -308,7 +308,7 @@ class ContactView(generic.ObjectView): ) assignments_table = tables.ContactAssignmentTable(contact_assignments) assignments_table.columns.hide('contact') - paginate_table(assignments_table, request) + configure_table(assignments_table, request) return { 'assignments_table': assignments_table, diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py index 23e94e8ef..035ca6840 100644 --- a/netbox/users/tests/test_preferences.py +++ b/netbox/users/tests/test_preferences.py @@ -1,7 +1,13 @@ from django.contrib.auth.models import User -from django.test import override_settings, TestCase +from django.test import override_settings +from django.test.client import RequestFactory +from django.urls import reverse +from dcim.models import Site +from dcim.tables import SiteTable from users.preferences import UserPreference +from utilities.tables import configure_table +from utilities.testing import TestCase DEFAULT_USER_PREFERENCES = { @@ -12,6 +18,7 @@ DEFAULT_USER_PREFERENCES = { class UserPreferencesTest(TestCase): + user_permissions = ['dcim.view_site'] def test_userpreference(self): CHOICES = ( @@ -37,3 +44,21 @@ class UserPreferencesTest(TestCase): userconfig = user.config self.assertEqual(userconfig.data, DEFAULT_USER_PREFERENCES) + + def test_table_ordering(self): + url = reverse('dcim:site_list') + response = self.client.get(f"{url}?sort=status") + self.assertEqual(response.status_code, 200) + + # Check that table ordering preference has been recorded + self.user.refresh_from_db() + ordering = self.user.config.get(f'tables.SiteTable.ordering') + self.assertEqual(ordering, ['status']) + + # Check that a recorded preference is honored by default + self.user.config.set(f'tables.SiteTable.ordering', ['-status'], commit=True) + table = SiteTable(Site.objects.all()) + request = RequestFactory().get(url) + request.user = self.user + configure_table(table, request) + self.assertEqual(table.order_by, ('-status',)) diff --git a/netbox/utilities/tables/__init__.py b/netbox/utilities/tables/__init__.py index 37dd75144..25fa95296 100644 --- a/netbox/utilities/tables/__init__.py +++ b/netbox/utilities/tables/__init__.py @@ -5,14 +5,23 @@ from .columns import * from .tables import * -# -# Pagination -# - -def paginate_table(table, request): +def configure_table(table, request): """ Paginate a table given a request context. """ + # Save ordering preference + if request.user.is_authenticated: + table_name = table.__class__.__name__ + if table.prefixed_order_by_field in request.GET: + # If an ordering has been specified as a query parameter, save it as the + # user's preferred ordering for this table. + ordering = request.GET.getlist(table.prefixed_order_by_field) + request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) + elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): + # If no ordering has been specified, set the preferred ordering (if any). + table.order_by = ordering + + # Paginate the table results paginate = { 'paginator_class': EnhancedPaginator, 'per_page': get_paginate_count(request) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 742d6d9ea..0fc8c9bf7 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,7 +11,7 @@ from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -41,7 +41,7 @@ class ClusterTypeView(generic.ObjectView): vm_count=count_related(VirtualMachine, 'cluster') ) clusters_table = tables.ClusterTable(clusters, exclude=('type',)) - paginate_table(clusters_table, request) + configure_table(clusters_table, request) return { 'clusters_table': clusters_table, @@ -103,7 +103,7 @@ class ClusterGroupView(generic.ObjectView): vm_count=count_related(VirtualMachine, 'cluster') ) clusters_table = tables.ClusterTable(clusters, exclude=('group',)) - paginate_table(clusters_table, request) + configure_table(clusters_table, request) return { 'clusters_table': clusters_table, diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index dd1e760bb..443cf8eef 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,6 +1,6 @@ from dcim.models import Interface from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import * @@ -31,7 +31,7 @@ class WirelessLANGroupView(generic.ObjectView): group=instance ) wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',)) - paginate_table(wirelesslans_table, request) + configure_table(wirelesslans_table, request) return { 'wirelesslans_table': wirelesslans_table, @@ -99,7 +99,7 @@ class WirelessLANView(generic.ObjectView): wireless_lans=instance ) interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces) - paginate_table(interfaces_table, request) + configure_table(interfaces_table, request) return { 'interfaces_table': interfaces_table,