diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index d148a4b3a..35d623ff0 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -310,7 +310,11 @@ class TableConfigForm(forms.ModelForm): ) fieldsets = ( - FieldSet('name', 'slug', 'object_type', 'description', 'weight', 'enabled', 'shared', name=_('Table Config')), + FieldSet( + 'name', 'slug', 'object_type', 'table', 'description', 'weight', 'enabled', 'shared', + name=_('Table Config') + ), + FieldSet('columns', 'ordering', name=_('Configuration')), ) class Meta: diff --git a/netbox/extras/migrations/0126_tableconfig.py b/netbox/extras/migrations/0126_tableconfig.py index 1658fff76..f13699286 100644 --- a/netbox/extras/migrations/0126_tableconfig.py +++ b/netbox/extras/migrations/0126_tableconfig.py @@ -1,5 +1,3 @@ -# Generated by Django 5.2b1 on 2025-04-04 17:35 - import django.contrib.postgres.fields import django.db.models.deletion from django.conf import settings @@ -29,22 +27,12 @@ class Migration(migrations.Migration): ('shared', models.BooleanField(default=True)), ( 'columns', - django.contrib.postgres.fields.ArrayField( - base_field=django.contrib.postgres.fields.ArrayField( - base_field=models.CharField(max_length=100), size=None - ), - size=None, - ), + django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None), ), ( 'ordering', django.contrib.postgres.fields.ArrayField( - base_field=django.contrib.postgres.fields.ArrayField( - base_field=models.CharField(max_length=100), size=None - ), - blank=True, - null=True, - size=None, + base_field=models.CharField(max_length=100), blank=True, null=True, size=None ), ), ( diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e0a986b03..9fe5ca153 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -571,12 +571,12 @@ class TableConfig(ChangeLoggedModel): default=True ) columns = ArrayField( - ArrayField(base_field=models.CharField(max_length=100)), + base_field=models.CharField(max_length=100), ) ordering = ArrayField( - ArrayField(base_field=models.CharField(max_length=100)), + base_field=models.CharField(max_length=100), blank=True, - null=True + null=True, ) class Meta: diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 3e24b18d1..bbb33d18d 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -287,7 +287,7 @@ class TableConfigTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - object_type = columns.ContentTypesColumn( + object_type = columns.ContentTypeColumn( verbose_name=_('Object Type'), ) table = tables.Column( diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 2d2c430aa..b5e7715ba 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -3,7 +3,6 @@ from functools import cached_property import django_tables2 as tables from django.conf import settings -from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import FieldDoesNotExist from django.db.models.fields.related import RelatedField @@ -20,8 +19,8 @@ from extras.models import CustomField, CustomLink from netbox.constants import EMPTY_TABLE_TEXT from netbox.registry import registry from netbox.tables import columns -from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.html import highlight +from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.string import title from utilities.views import get_viewname from .template_code import * @@ -58,40 +57,6 @@ class BaseTable(tables.Table): if self.empty_text is None: self.empty_text = _("No {model_name} found").format(model_name=self._meta.model._meta.verbose_name_plural) - # Determine the table columns to display by checking the following: - # 1. User's configuration for the table - # 2. Meta.default_columns - # 3. Meta.fields - selected_columns = None - if user is not None and not isinstance(user, AnonymousUser): - selected_columns = user.config.get(f"tables.{self.name}.columns") - elif isinstance(user, AnonymousUser) and hasattr(settings, 'DEFAULT_USER_PREFERENCES'): - selected_columns = settings.DEFAULT_USER_PREFERENCES.get('tables', {}).get(self.name, {}).get('columns') - if not selected_columns: - selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields) - - # Hide non-selected columns which are not exempt - for column in self.columns: - if column.name not in [*selected_columns, *self.exempt_columns]: - self.columns.hide(column.name) - - # Rearrange the sequence to list selected columns first, followed by all remaining columns - # TODO: There's probably a more clever way to accomplish this - self.sequence = [ - *[c for c in selected_columns if c in self.columns.names()], - *[c for c in self.columns.names() if c not in selected_columns] - ] - - # PK column should always come first - if 'pk' in self.sequence: - self.sequence.remove('pk') - self.sequence.insert(0, 'pk') - - # Actions column should always come last - if 'actions' in self.sequence: - self.sequence.remove('actions') - self.sequence.append('actions') - # Dynamically update the table's QuerySet to ensure related fields are pre-fetched if isinstance(self.data, TableQuerysetData): @@ -147,25 +112,70 @@ class BaseTable(tables.Table): self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) return self._objects_count + def _set_columns(self, selected_columns): + """ + Update the table sequence to display only the named columns and any exempt columns. + """ + if not selected_columns: + raise Exception('wtf?') + + # Hide non-selected columns which are not exempt + for column in self.columns: + if column.name not in [*selected_columns, *self.exempt_columns]: + self.columns.hide(column.name) + + # Rearrange the sequence to list selected columns first, followed by all remaining columns + # TODO: There's probably a more clever way to accomplish this + self.sequence = [ + *[c for c in selected_columns if c in self.columns.names()], + *[c for c in self.columns.names() if c not in selected_columns] + ] + + # PK column should always come first + if 'pk' in self.sequence: + self.sequence.remove('pk') + self.sequence.insert(0, 'pk') + + # Actions column should always come last + if 'actions' in self.sequence: + self.sequence.remove('actions') + self.sequence.append('actions') + def configure(self, request): """ Configure the table for a specific request context. This performs pagination and records - the user's preferred ordering logic. + the user's preferred columns & ordering logic. """ - # Save ordering preference - if request.user.is_authenticated: - if self.prefixed_order_by_field in request.GET: - if request.GET[self.prefixed_order_by_field]: - # 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(self.prefixed_order_by_field) - request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True) - else: - # If the ordering has been set to none (empty), clear any existing preference. - request.user.config.clear(f'tables.{self.name}.ordering', commit=True) - elif ordering := request.user.config.get(f'tables.{self.name}.ordering'): - # If no ordering has been specified, set the preferred ordering (if any). - self.order_by = ordering + columns = None + ordering = None + + if self.prefixed_order_by_field in request.GET: + if request.GET[self.prefixed_order_by_field]: + # 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(self.prefixed_order_by_field) + request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True) + else: + # If the ordering has been set to none (empty), clear any existing preference. + request.user.config.clear(f'tables.{self.name}.ordering', commit=True) + + # If the user has a saved preference, apply it + if request.user.is_authenticated and (userconfig := request.user.config): + if columns is None: + columns = userconfig.get(f"tables.{self.name}.columns") + if ordering is None: + ordering = userconfig.get(f"tables.{self.name}.ordering") + + # Fall back to the default columns & ordering + if columns is None: + if hasattr(settings, 'DEFAULT_USER_PREFERENCES'): + columns = settings.DEFAULT_USER_PREFERENCES.get('tables', {}).get(self.name, {}).get('columns') + else: + columns = getattr(self.Meta, 'default_columns', self.Meta.fields) + + self._set_columns(columns) + if ordering is not None: + self.order_by = ordering # Paginate the table results paginate = { @@ -174,6 +184,13 @@ class BaseTable(tables.Table): } tables.RequestConfig(request, paginate).configure(self) + @property + def configuration(self): + return { + 'columns': ','.join([c[0] for c in self.selected_columns]), + 'ordering': self.order_by, + } + class NetBoxTable(BaseTable): """ diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 447e2a6c5..783c5b8cb 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -21,7 +21,7 @@ from mptt.models import MPTTModel from core.models import ObjectType from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices -from extras.models import CustomField, ExportTemplate +from extras.models import CustomField, ExportTemplate, TableConfig from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields @@ -135,6 +135,15 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): model = self.queryset.model object_type = ObjectType.objects.get_for_model(model) + # If a TableConfig has been specified, apply it & update the user's saved preference + if tableconfig_id := request.GET.get('tableconfig_id'): + tableconfig = get_object_or_404(TableConfig, pk=tableconfig_id) + if request.user.is_authenticated: + table = self.table.__name__ + request.user.config.set(f'tables.{table}.columns', tableconfig.columns, commit=True) + request.user.config.set(f'tables.{table}.ordering', tableconfig.ordering, commit=True) + return redirect(request.path) + if self.filterset: self.queryset = self.filterset(request.GET, self.queryset, request=request).qs @@ -170,6 +179,13 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): # Render the objects table table = self.get_table(self.queryset, request, has_bulk_actions) + # Retrieve available configurations for the table + table_configs = TableConfig.objects.filter( + object_type=object_type, + table=table.name, + shared=True + ) + # If this is an HTMX request, return only the rendered table HTML if htmx_partial(request): if request.GET.get('embedded', False): @@ -186,6 +202,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): context = { 'model': model, 'table': table, + 'table_configs': table_configs, 'actions': actions, 'filter_form': self.filterset_form(request.GET) if self.filterset_form else None, 'prerequisite_model': get_prerequisite_model(self.queryset), diff --git a/netbox/templates/inc/table_controls_htmx.html b/netbox/templates/inc/table_controls_htmx.html index a07b319ef..e312008c9 100644 --- a/netbox/templates/inc/table_controls_htmx.html +++ b/netbox/templates/inc/table_controls_htmx.html @@ -26,12 +26,22 @@