Przeglądaj źródła

Closes #6954: Remember users' table ordering preferences

jeremystretch 4 lat temu
rodzic
commit
21e0e6e495

+ 7 - 6
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                      |

+ 1 - 0
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

+ 4 - 4
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,

+ 8 - 8
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,

+ 4 - 4
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 "<app>/<model>.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(

+ 6 - 6
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 = {

+ 3 - 4
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):

+ 5 - 5
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,

+ 26 - 1
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',))

+ 14 - 5
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)

+ 3 - 3
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,

+ 3 - 3
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,