Update table configuration logic to support TableConfigs

This commit is contained in:
Jeremy Stretch 2025-04-04 16:39:33 -04:00
parent 0033c08c25
commit 475dd7247b
7 changed files with 111 additions and 75 deletions

View File

@ -310,7 +310,11 @@ class TableConfigForm(forms.ModelForm):
) )
fieldsets = ( 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: class Meta:

View File

@ -1,5 +1,3 @@
# Generated by Django 5.2b1 on 2025-04-04 17:35
import django.contrib.postgres.fields import django.contrib.postgres.fields
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
@ -29,22 +27,12 @@ class Migration(migrations.Migration):
('shared', models.BooleanField(default=True)), ('shared', models.BooleanField(default=True)),
( (
'columns', 'columns',
django.contrib.postgres.fields.ArrayField( django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None),
base_field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=100), size=None
),
size=None,
),
), ),
( (
'ordering', 'ordering',
django.contrib.postgres.fields.ArrayField( django.contrib.postgres.fields.ArrayField(
base_field=django.contrib.postgres.fields.ArrayField( base_field=models.CharField(max_length=100), blank=True, null=True, size=None
base_field=models.CharField(max_length=100), size=None
),
blank=True,
null=True,
size=None,
), ),
), ),
( (

View File

@ -571,12 +571,12 @@ class TableConfig(ChangeLoggedModel):
default=True default=True
) )
columns = ArrayField( columns = ArrayField(
ArrayField(base_field=models.CharField(max_length=100)), base_field=models.CharField(max_length=100),
) )
ordering = ArrayField( ordering = ArrayField(
ArrayField(base_field=models.CharField(max_length=100)), base_field=models.CharField(max_length=100),
blank=True, blank=True,
null=True null=True,
) )
class Meta: class Meta:

View File

@ -287,7 +287,7 @@ class TableConfigTable(NetBoxTable):
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True linkify=True
) )
object_type = columns.ContentTypesColumn( object_type = columns.ContentTypeColumn(
verbose_name=_('Object Type'), verbose_name=_('Object Type'),
) )
table = tables.Column( table = tables.Column(

View File

@ -3,7 +3,6 @@ from functools import cached_property
import django_tables2 as tables import django_tables2 as tables
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField 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.constants import EMPTY_TABLE_TEXT
from netbox.registry import registry from netbox.registry import registry
from netbox.tables import columns from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.html import highlight from utilities.html import highlight
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.string import title from utilities.string import title
from utilities.views import get_viewname from utilities.views import get_viewname
from .template_code import * from .template_code import *
@ -58,40 +57,6 @@ class BaseTable(tables.Table):
if self.empty_text is None: if self.empty_text is None:
self.empty_text = _("No {model_name} found").format(model_name=self._meta.model._meta.verbose_name_plural) 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 # Dynamically update the table's QuerySet to ensure related fields are pre-fetched
if isinstance(self.data, TableQuerysetData): 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')) self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
return self._objects_count 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): def configure(self, request):
""" """
Configure the table for a specific request context. This performs pagination and records 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 columns = None
if request.user.is_authenticated: ordering = None
if self.prefixed_order_by_field in request.GET:
if request.GET[self.prefixed_order_by_field]: if self.prefixed_order_by_field in request.GET:
# If an ordering has been specified as a query parameter, save it as the if request.GET[self.prefixed_order_by_field]:
# user's preferred ordering for this table. # If an ordering has been specified as a query parameter, save it as the
ordering = request.GET.getlist(self.prefixed_order_by_field) # user's preferred ordering for this table.
request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True) ordering = request.GET.getlist(self.prefixed_order_by_field)
else: request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
# If the ordering has been set to none (empty), clear any existing preference. else:
request.user.config.clear(f'tables.{self.name}.ordering', commit=True) # If the ordering has been set to none (empty), clear any existing preference.
elif ordering := request.user.config.get(f'tables.{self.name}.ordering'): request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
# If no ordering has been specified, set the preferred ordering (if any).
self.order_by = ordering # 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 the table results
paginate = { paginate = {
@ -174,6 +184,13 @@ class BaseTable(tables.Table):
} }
tables.RequestConfig(request, paginate).configure(self) 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): class NetBoxTable(BaseTable):
""" """

View File

@ -21,7 +21,7 @@ from mptt.models import MPTTModel
from core.models import ObjectType from core.models import ObjectType
from core.signals import clear_events from core.signals import clear_events
from extras.choices import CustomFieldUIEditableChoices 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.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
@ -135,6 +135,15 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
model = self.queryset.model model = self.queryset.model
object_type = ObjectType.objects.get_for_model(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: if self.filterset:
self.queryset = self.filterset(request.GET, self.queryset, request=request).qs self.queryset = self.filterset(request.GET, self.queryset, request=request).qs
@ -170,6 +179,13 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Render the objects table # Render the objects table
table = self.get_table(self.queryset, request, has_bulk_actions) 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 this is an HTMX request, return only the rendered table HTML
if htmx_partial(request): if htmx_partial(request):
if request.GET.get('embedded', False): if request.GET.get('embedded', False):
@ -186,6 +202,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
context = { context = {
'model': model, 'model': model,
'table': table, 'table': table,
'table_configs': table_configs,
'actions': actions, 'actions': actions,
'filter_form': self.filterset_form(request.GET) if self.filterset_form else None, 'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
'prerequisite_model': get_prerequisite_model(self.queryset), 'prerequisite_model': get_prerequisite_model(self.queryset),

View File

@ -26,12 +26,22 @@
<div class="col-auto ms-auto d-print-none"> <div class="col-auto ms-auto d-print-none">
{% if request.user.is_authenticated and table_modal %} {% if request.user.is_authenticated and table_modal %}
<div class="table-configure input-group"> <div class="table-configure btn-group">
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" <button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#{{ table_modal }}" class="btn">
data-bs-target="#{{ table_modal }}"
class="btn">
<i class="mdi mdi-cog"></i> {% trans "Configure Table" %} <i class="mdi mdi-cog"></i> {% trans "Configure Table" %}
</button> </button>
<button type="button" class="btn dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="{% url 'extras:tableconfig_add' %}?object_type={{ table.Meta.model|content_type_id }}&table={{ table.name }}&columns={{ table.configuration.columns }}&ordering={{ table.configuration.ordering }}">Save</a>
{% if table_configs %}
<hr class="dropdown-divider">
{% for config in table_configs %}
<a class="dropdown-item" href="?tableconfig_id={{ config.pk }}">{{ config }}</a>
{% endfor %}
{% endif %}
</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>