Closes #14591: Saved table configurations (#19101)

* Add SavedTableConfig

* Update table configuration logic to support TableConfigs

* Update table config link when updating table

* Correct docstring

* Misc cleanup

* Use multi-select widgets for column selection

* Return null config params for tables with no model

* Fix auto-selection of selected columns

* Update migration

* Clean up template

* Enforce enabled/shared flags

* Search/filter by table name

* Misc cleanup

* Fix population of selected columns

* Ordering field should not be required

* Enable cloning for TableConfig

* Misc cleanup

* Add model documentation for TableConfig

* Drop slug field from TableConfig

* Improve TableConfig validation

* Remove add button from TableConfig list view

* Fix ordering validation to account for leading hyphens
This commit is contained in:
Jeremy Stretch 2025-04-10 16:48:02 -04:00 committed by GitHub
parent f8f2ad1d14
commit bb5057c063
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 850 additions and 96 deletions

View File

@ -0,0 +1,43 @@
# Table Configs
This object represents the saved configuration of an object table in NetBox. Table configs can be crafted, saved, and shared among users to apply specific views within object lists. Each table config can specify which table columns to display, the order in which to display them, and which columns are used for sorting.
For example, you might wish to create a table config for the devices list to assist in inventory tasks. This view might show the device name, location, serial number, and asset tag, but omit operational details like IP addresses. Once applied, this table config can be saved for reuse in future audits.
## Fields
### Name
A human-friendly name for the table config.
### User
The user to which this filter belongs. The current user will be assigned automatically when saving a table config via the UI, and cannot be changed.
### Object Type
The type of NetBox object to which the table config pertains.
### Table
The name of the specific table to which the table config pertains. (Some NetBox object use multiple tables.)
### Weight
A numeric weight used to influence the order in which table configs are listed. Table configs with a lower weight will be listed before those with a higher weight. Table configs having the same weight will be ordered alphabetically.
### Enabled
Determines whether this table config can be used. Disabled table configs will not appear as options in the UI, however they will be included in API results.
### Shared
Determines whether this table config is intended for use by all users or only its owner. Note that deselecting this option does **not** hide the table config from other users; it is merely excluded from the list of available table configs in UI object list views.
### Ordering
A list of column names by which the table is to be ordered. If left blank, the table's default ordering will be used.
### Columns
A list of columns to be displayed in the table. The table will render these columns in the order they appear in the list. At least one column must be selected.

View File

@ -233,6 +233,7 @@ nav:
- NotificationGroup: 'models/extras/notificationgroup.md' - NotificationGroup: 'models/extras/notificationgroup.md'
- SavedFilter: 'models/extras/savedfilter.md' - SavedFilter: 'models/extras/savedfilter.md'
- Subscription: 'models/extras/subscription.md' - Subscription: 'models/extras/subscription.md'
- TableConfig: 'models/extras/tableconfig.md'
- Tag: 'models/extras/tag.md' - Tag: 'models/extras/tag.md'
- Webhook: 'models/extras/webhook.md' - Webhook: 'models/extras/webhook.md'
- IPAM: - IPAM:

View File

@ -12,4 +12,5 @@ from .serializers_.configcontexts import *
from .serializers_.configtemplates import * from .serializers_.configtemplates import *
from .serializers_.savedfilters import * from .serializers_.savedfilters import *
from .serializers_.scripts import * from .serializers_.scripts import *
from .serializers_.tableconfigs import *
from .serializers_.tags import * from .serializers_.tags import *

View File

@ -0,0 +1,22 @@
from core.models import ObjectType
from extras.models import TableConfig
from netbox.api.fields import ContentTypeField
from netbox.api.serializers import ValidatedModelSerializer
__all__ = (
'TableConfigSerializer',
)
class TableConfigSerializer(ValidatedModelSerializer):
object_type = ContentTypeField(
queryset=ObjectType.objects.all()
)
class Meta:
model = TableConfig
fields = [
'id', 'url', 'display_url', 'display', 'object_type', 'table', 'name', 'description', 'user', 'weight',
'enabled', 'shared', 'columns', 'ordering', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'object_type', 'table')

View File

@ -14,6 +14,7 @@ router.register('custom-field-choice-sets', views.CustomFieldChoiceSetViewSet)
router.register('custom-links', views.CustomLinkViewSet) router.register('custom-links', views.CustomLinkViewSet)
router.register('export-templates', views.ExportTemplateViewSet) router.register('export-templates', views.ExportTemplateViewSet)
router.register('saved-filters', views.SavedFilterViewSet) router.register('saved-filters', views.SavedFilterViewSet)
router.register('table-configs', views.TableConfigViewSet)
router.register('bookmarks', views.BookmarkViewSet) router.register('bookmarks', views.BookmarkViewSet)
router.register('notifications', views.NotificationViewSet) router.register('notifications', views.NotificationViewSet)
router.register('notification-groups', views.NotificationGroupViewSet) router.register('notification-groups', views.NotificationGroupViewSet)

View File

@ -131,6 +131,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
filterset_class = filtersets.SavedFilterFilterSet filterset_class = filtersets.SavedFilterFilterSet
#
# Table Configs
#
class TableConfigViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = TableConfig.objects.all()
serializer_class = serializers.TableConfigSerializer
filterset_class = filtersets.TableConfigFilterSet
# #
# Bookmarks # Bookmarks
# #

View File

@ -32,6 +32,7 @@ __all__ = (
'ObjectTypeFilterSet', 'ObjectTypeFilterSet',
'SavedFilterFilterSet', 'SavedFilterFilterSet',
'ScriptFilterSet', 'ScriptFilterSet',
'TableConfigFilterSet',
'TagFilterSet', 'TagFilterSet',
'TaggedItemFilterSet', 'TaggedItemFilterSet',
'WebhookFilterSet', 'WebhookFilterSet',
@ -326,6 +327,59 @@ class SavedFilterFilterSet(ChangeLoggedModelFilterSet):
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user))) return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
class TableConfigFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ObjectType.objects.all(),
field_name='object_type'
)
object_type = ContentTypeFilter(
field_name='object_type'
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
to_field_name='username',
label=_('User (name)'),
)
usable = django_filters.BooleanFilter(
method='_usable'
)
class Meta:
model = TableConfig
fields = ('id', 'name', 'description', 'table', 'enabled', 'shared', 'weight')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(table__icontains=value)
)
def _usable(self, queryset, name, value):
"""
Return only TableConfigs that are both enabled and are shared (or belong to the current user).
"""
user = self.request.user if self.request else None
if not user or user.is_anonymous:
if value:
return queryset.filter(enabled=True, shared=True)
return queryset.filter(Q(enabled=False) | Q(shared=False))
if value:
return queryset.filter(enabled=True).filter(Q(shared=True) | Q(user=user))
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
class BookmarkFilterSet(BaseFilterSet): class BookmarkFilterSet(BaseFilterSet):
created = django_filters.DateTimeFilter() created = django_filters.DateTimeFilter()
object_type_id = MultiValueNumberFilter() object_type_id = MultiValueNumberFilter()

View File

@ -21,6 +21,7 @@ __all__ = (
'JournalEntryBulkEditForm', 'JournalEntryBulkEditForm',
'NotificationGroupBulkEditForm', 'NotificationGroupBulkEditForm',
'SavedFilterBulkEditForm', 'SavedFilterBulkEditForm',
'TableConfigBulkEditForm',
'TagBulkEditForm', 'TagBulkEditForm',
'WebhookBulkEditForm', 'WebhookBulkEditForm',
) )
@ -201,6 +202,34 @@ class SavedFilterBulkEditForm(BulkEditForm):
nullable_fields = ('description',) nullable_fields = ('description',)
class TableConfigBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=TableConfig.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
weight = forms.IntegerField(
label=_('Weight'),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect()
)
shared = forms.NullBooleanField(
label=_('Shared'),
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description',)
class WebhookBulkEditForm(NetBoxModelBulkEditForm): class WebhookBulkEditForm(NetBoxModelBulkEditForm):
model = Webhook model = Webhook

View File

@ -31,6 +31,7 @@ __all__ = (
'LocalConfigContextFilterForm', 'LocalConfigContextFilterForm',
'NotificationGroupFilterForm', 'NotificationGroupFilterForm',
'SavedFilterFilterForm', 'SavedFilterFilterForm',
'TableConfigFilterForm',
'TagFilterForm', 'TagFilterForm',
'WebhookFilterForm', 'WebhookFilterForm',
) )
@ -249,6 +250,36 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
) )
class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
)
object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'),
queryset=ObjectType.objects.public(),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
shared = forms.NullBooleanField(
label=_('Shared'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
weight = forms.IntegerField(
label=_('Weight'),
required=False
)
class WebhookFilterForm(NetBoxModelFilterSetForm): class WebhookFilterForm(NetBoxModelFilterSetForm):
model = Webhook model = Webhook
fieldsets = ( fieldsets = (

View File

@ -2,6 +2,7 @@ import json
import re import re
from django import forms from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -21,6 +22,7 @@ from utilities.forms.fields import (
) )
from utilities.forms.rendering import FieldSet, ObjectAttribute from utilities.forms.rendering import FieldSet, ObjectAttribute
from utilities.forms.widgets import ChoicesWidget, HTMXSelect from utilities.forms.widgets import ChoicesWidget, HTMXSelect
from utilities.tables import get_table_for_model
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = ( __all__ = (
@ -37,6 +39,7 @@ __all__ = (
'NotificationGroupForm', 'NotificationGroupForm',
'SavedFilterForm', 'SavedFilterForm',
'SubscriptionForm', 'SubscriptionForm',
'TableConfigForm',
'TagForm', 'TagForm',
'WebhookForm', 'WebhookForm',
) )
@ -301,6 +304,65 @@ class SavedFilterForm(forms.ModelForm):
super().__init__(*args, initial=initial, **kwargs) super().__init__(*args, initial=initial, **kwargs)
class TableConfigForm(forms.ModelForm):
object_type = ContentTypeChoiceField(
label=_('Object type'),
queryset=ObjectType.objects.all()
)
ordering = SimpleArrayField(
base_field=forms.CharField(),
required=False,
label=_('Ordering'),
help_text=_(
"Enter a comma-separated list of column names. Prepend a name with a hyphen to reverse the order."
)
)
available_columns = SimpleArrayField(
base_field=forms.CharField(),
required=False,
widget=forms.SelectMultiple(
attrs={'size': 10, 'class': 'form-select'}
),
label=_('Available Columns')
)
columns = SimpleArrayField(
base_field=forms.CharField(),
widget=forms.SelectMultiple(
attrs={'size': 10, 'class': 'form-select select-all'}
),
label=_('Selected Columns')
)
class Meta:
model = TableConfig
exclude = ('user',)
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
object_type = ObjectType.objects.get(pk=get_field_value(self, 'object_type'))
model = object_type.model_class()
table_name = get_field_value(self, 'table')
table_class = get_table_for_model(model, table_name)
table = table_class([])
if columns := self._get_columns():
table._set_columns(columns)
# Initialize columns field based on table attributes
self.fields['available_columns'].widget.choices = table.available_columns
self.fields['columns'].widget.choices = table.selected_columns
def _get_columns(self):
if self.is_bound and (columns := self.data.getlist('columns')):
return columns
if 'columns' in self.initial:
columns = self.get_initial_for_field(self.fields['columns'], 'columns')
return columns.split(',') if type(columns) is str else columns
if self.instance is not None:
return self.instance.columns
class BookmarkForm(forms.ModelForm): class BookmarkForm(forms.ModelForm):
object_type = ContentTypeChoiceField( object_type = ContentTypeChoiceField(
label=_('Object type'), label=_('Object type'),

View File

@ -34,6 +34,7 @@ __all__ = (
'JournalEntryFilter', 'JournalEntryFilter',
'NotificationGroupFilter', 'NotificationGroupFilter',
'SavedFilterFilter', 'SavedFilterFilter',
'TableConfigFilter',
'TagFilter', 'TagFilter',
'WebhookFilter', 'WebhookFilter',
) )
@ -262,6 +263,19 @@ class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
) )
@strawberry_django.filter(models.TableConfig, lookups=True)
class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
user_id: ID | None = strawberry_django.filter_field()
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
shared: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Tag, lookups=True) @strawberry_django.filter(models.Tag, lookups=True)
class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin): class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()

View File

@ -32,6 +32,9 @@ class ExtrasQuery:
saved_filter: SavedFilterType = strawberry_django.field() saved_filter: SavedFilterType = strawberry_django.field()
saved_filter_list: List[SavedFilterType] = strawberry_django.field() saved_filter_list: List[SavedFilterType] = strawberry_django.field()
table_config: TableConfigType = strawberry_django.field()
table_config_list: List[TableConfigType] = strawberry_django.field()
journal_entry: JournalEntryType = strawberry_django.field() journal_entry: JournalEntryType = strawberry_django.field()
journal_entry_list: List[JournalEntryType] = strawberry_django.field() journal_entry_list: List[JournalEntryType] = strawberry_django.field()

View File

@ -38,6 +38,7 @@ __all__ = (
'NotificationType', 'NotificationType',
'SavedFilterType', 'SavedFilterType',
'SubscriptionType', 'SubscriptionType',
'TableConfigType',
'TagType', 'TagType',
'WebhookType', 'WebhookType',
) )
@ -186,6 +187,16 @@ class SubscriptionType(ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
models.TableConfig,
fields='__all__',
filters=TableConfigFilter,
pagination=True
)
class TableConfigType(ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type( @strawberry_django.type(
models.Tag, models.Tag,
exclude=['extras_taggeditem_items', ], exclude=['extras_taggeditem_items', ],

View File

@ -0,0 +1,56 @@
import django.contrib.postgres.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_remove_redundant_indexes'),
('extras', '0126_configtemplate_as_attachment_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TableConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('table', models.CharField(max_length=100)),
('name', models.CharField(max_length=100)),
('description', models.CharField(blank=True, max_length=200)),
('weight', models.PositiveSmallIntegerField(default=100)),
('enabled', models.BooleanField(default=True)),
('shared', models.BooleanField(default=True)),
(
'columns',
django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None),
),
(
'ordering',
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=100), blank=True, null=True, size=None
),
),
(
'object_type',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='table_configs', to='core.objecttype'
),
),
(
'user',
models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
],
options={
'verbose_name': 'table config',
'verbose_name_plural': 'table configs',
'ordering': ('weight', 'name'),
},
),
]

View File

@ -27,6 +27,7 @@ from utilities.html import clean_html
from utilities.jinja2 import render_jinja2 from utilities.jinja2 import render_jinja2
from utilities.querydict import dict_to_querydict from utilities.querydict import dict_to_querydict
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.tables import get_table_for_model
__all__ = ( __all__ = (
'Bookmark', 'Bookmark',
@ -36,6 +37,7 @@ __all__ = (
'ImageAttachment', 'ImageAttachment',
'JournalEntry', 'JournalEntry',
'SavedFilter', 'SavedFilter',
'TableConfig',
'Webhook', 'Webhook',
) )
@ -524,6 +526,121 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
return qd.urlencode() return qd.urlencode()
class TableConfig(CloningMixin, ChangeLoggedModel):
"""
A saved configuration of columns and ordering which applies to a specific table.
"""
object_type = models.ForeignKey(
to='core.ObjectType',
on_delete=models.CASCADE,
related_name='table_configs',
help_text=_("The table's object type"),
)
table = models.CharField(
verbose_name=_('table'),
max_length=100,
)
name = models.CharField(
verbose_name=_('name'),
max_length=100,
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True,
)
user = models.ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
blank=True,
null=True,
)
weight = models.PositiveSmallIntegerField(
verbose_name=_('weight'),
default=100
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True
)
shared = models.BooleanField(
verbose_name=_('shared'),
default=True
)
columns = ArrayField(
base_field=models.CharField(max_length=100),
)
ordering = ArrayField(
base_field=models.CharField(max_length=100),
blank=True,
null=True,
)
clone_fields = ('object_type', 'table', 'enabled', 'shared', 'columns', 'ordering')
class Meta:
ordering = ('weight', 'name')
verbose_name = _('table config')
verbose_name_plural = _('table configs')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:tableconfig', args=[self.pk])
@property
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/extras/tableconfig/'
@property
def table_class(self):
return get_table_for_model(self.object_type.model_class(), name=self.table)
@property
def ordering_items(self):
"""
Return a list of two-tuples indicating the column(s) by which the table is to be ordered and a boolean for each
column indicating whether its ordering is ascending.
"""
items = []
for col in self.ordering or []:
if col.startswith('-'):
ascending = False
col = col[1:]
else:
ascending = True
items.append((col, ascending))
return items
def clean(self):
super().clean()
# Validate table
if self.table_class is None:
raise ValidationError({
'table': _("Unknown table: {name}").format(name=self.table)
})
table = self.table_class([])
# Validate ordering columns
for name in self.ordering:
if name.startswith('-'):
name = name[1:] # Strip leading hyphen
if name not in table.columns:
raise ValidationError({
'ordering': _('Unknown column: {name}').format(name=name)
})
# Validate selected columns
for name in self.columns:
if name not in table.columns:
raise ValidationError({
'columns': _('Unknown column: {name}').format(name=name)
})
class ImageAttachment(ChangeLoggedModel): class ImageAttachment(ChangeLoggedModel):
""" """
An uploaded image which is associated with an object. An uploaded image which is associated with an object.

View File

@ -27,6 +27,7 @@ __all__ = (
'ReportResultsTable', 'ReportResultsTable',
'ScriptResultsTable', 'ScriptResultsTable',
'SubscriptionTable', 'SubscriptionTable',
'TableConfigTable',
'TaggedItemTable', 'TaggedItemTable',
'TagTable', 'TagTable',
'WebhookTable', 'WebhookTable',
@ -281,6 +282,36 @@ class SavedFilterTable(NetBoxTable):
) )
class TableConfigTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
object_type = columns.ContentTypeColumn(
verbose_name=_('Object Type'),
)
table = tables.Column(
verbose_name=_('Table Name')
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
shared = columns.BooleanColumn(
verbose_name=_('Shared'),
false_mark=None
)
class Meta(NetBoxTable.Meta):
model = TableConfig
fields = (
'pk', 'id', 'name', 'object_type', 'table', 'description', 'user', 'weight', 'enabled', 'shared', 'created',
'last_updated',
)
default_columns = (
'pk', 'name', 'object_type', 'table', 'user', 'description', 'enabled', 'shared',
)
class BookmarkTable(NetBoxTable): class BookmarkTable(NetBoxTable):
object_type = columns.ContentTypeColumn( object_type = columns.ContentTypeColumn(
verbose_name=_('Object Types'), verbose_name=_('Object Types'),

View File

@ -19,6 +19,9 @@ urlpatterns = [
path('export-templates/', include(get_model_urls('extras', 'exporttemplate', detail=False))), path('export-templates/', include(get_model_urls('extras', 'exporttemplate', detail=False))),
path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))), path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))),
path('table-configs/', include(get_model_urls('extras', 'tableconfig', detail=False))),
path('table-configs/<int:pk>/', include(get_model_urls('extras', 'tableconfig'))),
path('saved-filters/', include(get_model_urls('extras', 'savedfilter', detail=False))), path('saved-filters/', include(get_model_urls('extras', 'savedfilter', detail=False))),
path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))), path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),

View File

@ -2,12 +2,14 @@ import importlib
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import models from django.db import models
from django.db.models import Q
from taggit.managers import _TaggableManager from taggit.managers import _TaggableManager
from netbox.context import current_request from netbox.context import current_request
from .validators import CustomValidator from .validators import CustomValidator
__all__ = ( __all__ = (
'SharedObjectViewMixin',
'image_upload', 'image_upload',
'is_report', 'is_report',
'is_script', 'is_script',
@ -16,6 +18,22 @@ __all__ = (
) )
class SharedObjectViewMixin:
def get_queryset(self, request):
"""
Return only shared objects, or those owned by the current user, unless this is a superuser.
"""
queryset = super().get_queryset(request)
if request.user.is_superuser:
return queryset
if request.user.is_anonymous:
return queryset.filter(shared=True)
return queryset.filter(
Q(shared=True) | Q(user=request.user)
)
def filename_from_model(model: models.Model) -> str: def filename_from_model(model: models.Model) -> str:
"""Standardises how we generate filenames from model class for exports""" """Standardises how we generate filenames from model class for exports"""
base = model._meta.verbose_name_plural.lower().replace(' ', '_') base = model._meta.verbose_name_plural.lower().replace(' ', '_')

View File

@ -18,6 +18,7 @@ from dcim.models import Device, DeviceRole, Platform
from extras.choices import LogLevelChoices from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class from extras.dashboard.utils import get_widget_class
from extras.utils import SharedObjectViewMixin
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.mixins import TableMixin from netbox.views.generic.mixins import TableMixin
@ -285,39 +286,22 @@ class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView):
# Saved filters # Saved filters
# #
class SavedFilterMixin:
def get_queryset(self, request):
"""
Return only shared SavedFilters, or those owned by the current user, unless
this is a superuser.
"""
queryset = SavedFilter.objects.all()
user = request.user
if user.is_superuser:
return queryset
if user.is_anonymous:
return queryset.filter(shared=True)
return queryset.filter(
Q(shared=True) | Q(user=user)
)
@register_model_view(SavedFilter, 'list', path='', detail=False) @register_model_view(SavedFilter, 'list', path='', detail=False)
class SavedFilterListView(SavedFilterMixin, generic.ObjectListView): class SavedFilterListView(SharedObjectViewMixin, generic.ObjectListView):
queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet filterset = filtersets.SavedFilterFilterSet
filterset_form = forms.SavedFilterFilterForm filterset_form = forms.SavedFilterFilterForm
table = tables.SavedFilterTable table = tables.SavedFilterTable
@register_model_view(SavedFilter) @register_model_view(SavedFilter)
class SavedFilterView(SavedFilterMixin, generic.ObjectView): class SavedFilterView(SharedObjectViewMixin, generic.ObjectView):
queryset = SavedFilter.objects.all() queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'add', detail=False) @register_model_view(SavedFilter, 'add', detail=False)
@register_model_view(SavedFilter, 'edit') @register_model_view(SavedFilter, 'edit')
class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView): class SavedFilterEditView(SharedObjectViewMixin, generic.ObjectEditView):
queryset = SavedFilter.objects.all() queryset = SavedFilter.objects.all()
form = forms.SavedFilterForm form = forms.SavedFilterForm
@ -328,18 +312,18 @@ class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView):
@register_model_view(SavedFilter, 'delete') @register_model_view(SavedFilter, 'delete')
class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView): class SavedFilterDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView):
queryset = SavedFilter.objects.all() queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'bulk_import', detail=False) @register_model_view(SavedFilter, 'bulk_import', detail=False)
class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView): class SavedFilterBulkImportView(SharedObjectViewMixin, generic.BulkImportView):
queryset = SavedFilter.objects.all() queryset = SavedFilter.objects.all()
model_form = forms.SavedFilterImportForm model_form = forms.SavedFilterImportForm
@register_model_view(SavedFilter, 'bulk_edit', path='edit', detail=False) @register_model_view(SavedFilter, 'bulk_edit', path='edit', detail=False)
class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView): class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
queryset = SavedFilter.objects.all() queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet filterset = filtersets.SavedFilterFilterSet
table = tables.SavedFilterTable table = tables.SavedFilterTable
@ -347,12 +331,71 @@ class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView):
@register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False) @register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False)
class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView): class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = SavedFilter.objects.all() queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet filterset = filtersets.SavedFilterFilterSet
table = tables.SavedFilterTable table = tables.SavedFilterTable
#
# Table configs
#
@register_model_view(TableConfig, 'list', path='', detail=False)
class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
queryset = TableConfig.objects.all()
filterset = filtersets.TableConfigFilterSet
filterset_form = forms.TableConfigFilterForm
table = tables.TableConfigTable
actions = {
'export': {'view'},
}
@register_model_view(TableConfig)
class TableConfigView(SharedObjectViewMixin, generic.ObjectView):
queryset = TableConfig.objects.all()
def get_extra_context(self, request, instance):
table = instance.table_class([])
return {
'columns': dict(table.columns.items()),
}
@register_model_view(TableConfig, 'add', detail=False)
@register_model_view(TableConfig, 'edit')
class TableConfigEditView(SharedObjectViewMixin, generic.ObjectEditView):
queryset = TableConfig.objects.all()
form = forms.TableConfigForm
template_name = 'extras/tableconfig_edit.html'
def alter_object(self, obj, request, url_args, url_kwargs):
if not obj.pk:
obj.user = request.user
return obj
@register_model_view(TableConfig, 'delete')
class TableConfigDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView):
queryset = TableConfig.objects.all()
@register_model_view(TableConfig, 'bulk_edit', path='edit', detail=False)
class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
queryset = TableConfig.objects.all()
filterset = filtersets.TableConfigFilterSet
table = tables.TableConfigTable
form = forms.TableConfigBulkEditForm
@register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False)
class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = TableConfig.objects.all()
filterset = filtersets.TableConfigFilterSet
table = tables.TableConfigTable
# #
# Bookmarks # Bookmarks
# #

View File

@ -349,6 +349,7 @@ CUSTOMIZATION_MENU = Menu(
get_model_item('extras', 'customlink', _('Custom Links')), get_model_item('extras', 'customlink', _('Custom Links')),
get_model_item('extras', 'exporttemplate', _('Export Templates')), get_model_item('extras', 'exporttemplate', _('Export Templates')),
get_model_item('extras', 'savedfilter', _('Saved Filters')), get_model_item('extras', 'savedfilter', _('Saved Filters')),
get_model_item('extras', 'tableconfig', _('Table Configs'), actions=()),
get_model_item('extras', 'tag', 'Tags'), get_model_item('extras', 'tag', 'Tags'),
get_model_item('extras', 'imageattachment', _('Image Attachments'), actions=()), get_model_item('extras', 'imageattachment', _('Image Attachments'), actions=()),
), ),

View File

@ -1,9 +1,9 @@
from copy import deepcopy from copy import deepcopy
from functools import cached_property from functools import cached_property
from urllib.parse import urlencode
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 +20,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 +58,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 +113,67 @@ 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.
"""
# 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 +182,25 @@ class BaseTable(tables.Table):
} }
tables.RequestConfig(request, paginate).configure(self) tables.RequestConfig(request, paginate).configure(self)
@property
def configuration(self):
config = {
'columns': ','.join([c[0] for c in self.selected_columns]),
}
if self.order_by:
config['ordering'] = self.order_by
return config
@property
def config_params(self):
if not (model := getattr(self.Meta, 'model', None)):
return None
return urlencode({
'object_type': ObjectType.objects.get_for_model(model).pk,
'table': self.name,
**self.configuration,
})
class NetBoxTable(BaseTable): class NetBoxTable(BaseTable):
""" """

View File

@ -7,7 +7,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import ManyToManyField, ProtectedError, RestrictedError from django.db.models import ManyToManyField, ProtectedError, Q, RestrictedError
from django.db.models.fields.reverse_related import ManyToManyRel from django.db.models.fields.reverse_related import ManyToManyRel
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
from django.http import HttpResponse from django.http import HttpResponse
@ -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)
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,14 @@ 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(
Q(shared=True) | Q(user=request.user if request.user.is_authenticated else None),
object_type=object_type,
table=table.name,
enabled=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 +203,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),

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,12 @@
import { getElements, scrollTo } from '../util'; import { getElements, scrollTo } from '../util';
function handleFormSubmit(event: Event, form: HTMLFormElement): void { function handleFormSubmit(event: Event, form: HTMLFormElement): void {
// Automatically select all options in any <select> with the "select-all" class. This is useful for
// multi-select fields that are used to add/remove choices.
for (const element of getElements<HTMLOptionElement>('select.select-all option')) {
element.selected = true;
}
// Track the names of each invalid field. // Track the names of each invalid field.
const invalids = new Set<string>(); const invalids = new Set<string>();

View File

@ -1,16 +1,6 @@
import { createToast } from './bs'; import { createToast } from './bs';
import { getElements, apiPatch, hasError, getSelectedOptions } from './util'; import { getElements, apiPatch, hasError, getSelectedOptions } from './util';
/**
* Mark each option element in the selected columns element as 'selected' so they are submitted to
* the API.
*/
function saveTableConfig(): void {
for (const element of getElements<HTMLOptionElement>('select[name="columns"] option')) {
element.selected = true;
}
}
/** /**
* Add columns to the table config select element. * Add columns to the table config select element.
*/ */
@ -124,9 +114,6 @@ function handleSubmit(event: Event): void {
* Initialize table configuration elements. * Initialize table configuration elements.
*/ */
export function initTableConfig(): void { export function initTableConfig(): void {
for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
element.addEventListener('click', saveTableConfig);
}
for (const element of getElements<HTMLButtonElement>('#add_columns')) { for (const element of getElements<HTMLButtonElement>('#add_columns')) {
element.addEventListener('click', addColumns); element.addEventListener('click', addColumns);
} }

View File

@ -0,0 +1,88 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Table Config" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Object Type" %}</th>
<td>{{ object.object_type }}</td>
</tr>
<tr>
<th scope="row">{% trans "Table" %}</th>
<td>{{ object.table }}</td>
</tr>
<tr>
<th scope="row">{% trans "User" %}</th>
<td>{{ object.user|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Enabled" %}</th>
<td>{% checkmark object.enabled %}</td>
</tr>
<tr>
<th scope="row">{% trans "Shared" %}</th>
<td>{% checkmark object.shared %}</td>
</tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>{{ object.weight }}</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Columns Displayed" %}</h2>
<ul class="list-group list-group-flush" role="presentation">
{% for name in object.columns %}
<li class="list-group-item list-group-item-action">
{% with column=columns|get_key:name %}
{{ column.verbose_name }}
{% endwith %}
</li>
{% endfor %}
</ul>
</div>
<div class="card">
<h2 class="card-header">{% trans "Ordering" %}</h2>
<ul class="list-group list-group-flush" role="presentation">
{% for column, ascending in object.ordering_items %}
<li class="list-group-item">
{% with column=columns|get_key:column %}
{% if ascending %}
<i class="mdi mdi-arrow-down-thick"></i>
{% else %}
<i class="mdi mdi-arrow-up-thick"></i>
{% endif %}
{{ column.verbose_name }}
{% endwith %}
</li>
{% empty %}
<li class="list-group-item text-muted">{% trans "Default" %}</li>
{% endfor %}
</ul>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,48 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% load i18n %}
{% block form %}
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Device" %}</h2>
</div>
{% render_field form.name %}
{% render_field form.object_type %}
{% render_field form.table %}
{% render_field form.description %}
{% render_field form.weight %}
{% render_field form.enabled %}
{% render_field form.shared %}
{% render_field form.ordering %}
</div>
<div class="field-group my-5">
<div class="row">
<div class="col-5 text-center">
{{ form.available_columns.label }}
{{ form.available_columns }}
</div>
<div class="col-2 d-flex align-items-center">
<div>
<a tabindex="0" class="btn btn-success btn-sm w-100 my-2" id="add_columns">
<i class="mdi mdi-arrow-right-bold"></i> {% trans "Add" %}
</a>
<a tabindex="0" class="btn btn-danger btn-sm w-100 my-2" id="remove_columns">
<i class="mdi mdi-arrow-left-bold"></i> {% trans "Remove" %}
</a>
</div>
</div>
<div class="col-5 text-center">
{{ form.columns.label }}
{{ form.columns }}
<a tabindex="0" class="btn btn-primary btn-sm mt-2" id="move-option-up" data-target="id_columns">
<i class="mdi mdi-arrow-up-bold"></i> {% trans "Move Up" %}
</a>
<a tabindex="0" class="btn btn-primary btn-sm mt-2" id="move-option-down" data-target="id_columns">
<i class="mdi mdi-arrow-down-bold"></i> {% trans "Move Down" %}
</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -19,6 +19,11 @@
{# Include the updated object count for display elsewhere on the page #} {# Include the updated object count for display elsewhere on the page #}
<div hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div> <div hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div>
{# Include the updated "save" link for the table configuration #}
{% if table.config_params %}
<a class="dropdown-item" hx-swap-oob="outerHTML:#table_save_link" href="{% url 'extras:tableconfig_add' %}?{{ table.config_params }}&return_url={{ request.path }}" id="table_save_link">Save</a>
{% endif %}
{# Update the bulk action buttons with new query parameters #} {# Update the bulk action buttons with new query parameters #}
{% if actions %} {% if actions %}
<div class="bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons"> <div class="bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">

View File

@ -26,12 +26,24 @@
<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">
{% if table.config_params %}
<a class="dropdown-item" href="{% url 'extras:tableconfig_add' %}?{{ table.config_params }}&return_url={{ request.path }}" id="table_save_link">Save</a>
{% endif %}
{% 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>

View File

@ -121,7 +121,7 @@ class TableConfigForm(forms.Form):
choices=[], choices=[],
required=False, required=False,
widget=forms.SelectMultiple( widget=forms.SelectMultiple(
attrs={'size': 10, 'class': 'form-select'} attrs={'size': 10, 'class': 'form-select select-all'}
), ),
label=_('Selected Columns') label=_('Selected Columns')
) )

View File

@ -1,13 +1,24 @@
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.registry import registry from netbox.registry import registry
__all__ = ( __all__ = (
'get_table_for_model',
'get_table_ordering', 'get_table_ordering',
'linkify_phone', 'linkify_phone',
'register_table_column' 'register_table_column'
) )
def get_table_for_model(model, name=None):
name = name or f'{model.__name__}Table'
try:
return import_string(f'{model._meta.app_label}.tables.{name}')
except ImportError:
return
def get_table_ordering(request, table): def get_table_ordering(request, table):
""" """
Given a request, return the prescribed table ordering, if any. This may be necessary to determine prior to rendering Given a request, return the prescribed table ordering, if any. This may be necessary to determine prior to rendering