mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-27 02:48:38 -06:00
Update table configuration logic to support TableConfigs
This commit is contained in:
parent
0033c08c25
commit
475dd7247b
@ -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:
|
||||||
|
@ -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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -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:
|
||||||
|
@ -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(
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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),
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user