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 = (
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:

View File

@ -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
),
),
(

View File

@ -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:

View File

@ -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(

View File

@ -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,13 +112,43 @@ 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:
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
@ -163,8 +158,23 @@ class BaseTable(tables.Table):
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).
# 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
@ -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):
"""

View File

@ -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),

View File

@ -26,12 +26,22 @@
<div class="col-auto ms-auto d-print-none">
{% if request.user.is_authenticated and table_modal %}
<div class="table-configure input-group">
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}"
data-bs-target="#{{ table_modal }}"
class="btn">
<div class="table-configure btn-group">
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#{{ table_modal }}" class="btn">
<i class="mdi mdi-cog"></i> {% trans "Configure Table" %}
</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>
{% endif %}
</div>