Skip to content

Commit bb5057c

Browse files
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
1 parent f8f2ad1 commit bb5057c

32 files changed

+856
-102
lines changed

docs/models/extras/tableconfig.md

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Table Configs
2+
3+
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.
4+
5+
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.
6+
7+
## Fields
8+
9+
### Name
10+
11+
A human-friendly name for the table config.
12+
13+
### User
14+
15+
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.
16+
17+
### Object Type
18+
19+
The type of NetBox object to which the table config pertains.
20+
21+
### Table
22+
23+
The name of the specific table to which the table config pertains. (Some NetBox object use multiple tables.)
24+
25+
### Weight
26+
27+
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.
28+
29+
### Enabled
30+
31+
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.
32+
33+
### Shared
34+
35+
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.
36+
37+
### Ordering
38+
39+
A list of column names by which the table is to be ordered. If left blank, the table's default ordering will be used.
40+
41+
### Columns
42+
43+
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.

mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ nav:
233233
- NotificationGroup: 'models/extras/notificationgroup.md'
234234
- SavedFilter: 'models/extras/savedfilter.md'
235235
- Subscription: 'models/extras/subscription.md'
236+
- TableConfig: 'models/extras/tableconfig.md'
236237
- Tag: 'models/extras/tag.md'
237238
- Webhook: 'models/extras/webhook.md'
238239
- IPAM:

netbox/extras/api/serializers.py

+1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
from .serializers_.configtemplates import *
1313
from .serializers_.savedfilters import *
1414
from .serializers_.scripts import *
15+
from .serializers_.tableconfigs import *
1516
from .serializers_.tags import *
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from core.models import ObjectType
2+
from extras.models import TableConfig
3+
from netbox.api.fields import ContentTypeField
4+
from netbox.api.serializers import ValidatedModelSerializer
5+
6+
__all__ = (
7+
'TableConfigSerializer',
8+
)
9+
10+
11+
class TableConfigSerializer(ValidatedModelSerializer):
12+
object_type = ContentTypeField(
13+
queryset=ObjectType.objects.all()
14+
)
15+
16+
class Meta:
17+
model = TableConfig
18+
fields = [
19+
'id', 'url', 'display_url', 'display', 'object_type', 'table', 'name', 'description', 'user', 'weight',
20+
'enabled', 'shared', 'columns', 'ordering', 'created', 'last_updated',
21+
]
22+
brief_fields = ('id', 'url', 'display', 'name', 'description', 'object_type', 'table')

netbox/extras/api/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
router.register('custom-links', views.CustomLinkViewSet)
1515
router.register('export-templates', views.ExportTemplateViewSet)
1616
router.register('saved-filters', views.SavedFilterViewSet)
17+
router.register('table-configs', views.TableConfigViewSet)
1718
router.register('bookmarks', views.BookmarkViewSet)
1819
router.register('notifications', views.NotificationViewSet)
1920
router.register('notification-groups', views.NotificationGroupViewSet)

netbox/extras/api/views.py

+11
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
131131
filterset_class = filtersets.SavedFilterFilterSet
132132

133133

134+
#
135+
# Table Configs
136+
#
137+
138+
class TableConfigViewSet(NetBoxModelViewSet):
139+
metadata_class = ContentTypeMetadata
140+
queryset = TableConfig.objects.all()
141+
serializer_class = serializers.TableConfigSerializer
142+
filterset_class = filtersets.TableConfigFilterSet
143+
144+
134145
#
135146
# Bookmarks
136147
#

netbox/extras/filtersets.py

+54
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
'ObjectTypeFilterSet',
3333
'SavedFilterFilterSet',
3434
'ScriptFilterSet',
35+
'TableConfigFilterSet',
3536
'TagFilterSet',
3637
'TaggedItemFilterSet',
3738
'WebhookFilterSet',
@@ -326,6 +327,59 @@ def _usable(self, queryset, name, value):
326327
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
327328

328329

330+
class TableConfigFilterSet(ChangeLoggedModelFilterSet):
331+
q = django_filters.CharFilter(
332+
method='search',
333+
label=_('Search'),
334+
)
335+
object_type_id = django_filters.ModelMultipleChoiceFilter(
336+
queryset=ObjectType.objects.all(),
337+
field_name='object_type'
338+
)
339+
object_type = ContentTypeFilter(
340+
field_name='object_type'
341+
)
342+
user_id = django_filters.ModelMultipleChoiceFilter(
343+
queryset=User.objects.all(),
344+
label=_('User (ID)'),
345+
)
346+
user = django_filters.ModelMultipleChoiceFilter(
347+
field_name='user__username',
348+
queryset=User.objects.all(),
349+
to_field_name='username',
350+
label=_('User (name)'),
351+
)
352+
usable = django_filters.BooleanFilter(
353+
method='_usable'
354+
)
355+
356+
class Meta:
357+
model = TableConfig
358+
fields = ('id', 'name', 'description', 'table', 'enabled', 'shared', 'weight')
359+
360+
def search(self, queryset, name, value):
361+
if not value.strip():
362+
return queryset
363+
return queryset.filter(
364+
Q(name__icontains=value) |
365+
Q(description__icontains=value) |
366+
Q(table__icontains=value)
367+
)
368+
369+
def _usable(self, queryset, name, value):
370+
"""
371+
Return only TableConfigs that are both enabled and are shared (or belong to the current user).
372+
"""
373+
user = self.request.user if self.request else None
374+
if not user or user.is_anonymous:
375+
if value:
376+
return queryset.filter(enabled=True, shared=True)
377+
return queryset.filter(Q(enabled=False) | Q(shared=False))
378+
if value:
379+
return queryset.filter(enabled=True).filter(Q(shared=True) | Q(user=user))
380+
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
381+
382+
329383
class BookmarkFilterSet(BaseFilterSet):
330384
created = django_filters.DateTimeFilter()
331385
object_type_id = MultiValueNumberFilter()

netbox/extras/forms/bulk_edit.py

+29
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
'JournalEntryBulkEditForm',
2222
'NotificationGroupBulkEditForm',
2323
'SavedFilterBulkEditForm',
24+
'TableConfigBulkEditForm',
2425
'TagBulkEditForm',
2526
'WebhookBulkEditForm',
2627
)
@@ -201,6 +202,34 @@ class SavedFilterBulkEditForm(BulkEditForm):
201202
nullable_fields = ('description',)
202203

203204

205+
class TableConfigBulkEditForm(BulkEditForm):
206+
pk = forms.ModelMultipleChoiceField(
207+
queryset=TableConfig.objects.all(),
208+
widget=forms.MultipleHiddenInput
209+
)
210+
description = forms.CharField(
211+
label=_('Description'),
212+
max_length=200,
213+
required=False
214+
)
215+
weight = forms.IntegerField(
216+
label=_('Weight'),
217+
required=False
218+
)
219+
enabled = forms.NullBooleanField(
220+
label=_('Enabled'),
221+
required=False,
222+
widget=BulkEditNullBooleanSelect()
223+
)
224+
shared = forms.NullBooleanField(
225+
label=_('Shared'),
226+
required=False,
227+
widget=BulkEditNullBooleanSelect()
228+
)
229+
230+
nullable_fields = ('description',)
231+
232+
204233
class WebhookBulkEditForm(NetBoxModelBulkEditForm):
205234
model = Webhook
206235

netbox/extras/forms/filtersets.py

+31
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
'LocalConfigContextFilterForm',
3232
'NotificationGroupFilterForm',
3333
'SavedFilterFilterForm',
34+
'TableConfigFilterForm',
3435
'TagFilterForm',
3536
'WebhookFilterForm',
3637
)
@@ -249,6 +250,36 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
249250
)
250251

251252

253+
class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
254+
fieldsets = (
255+
FieldSet('q', 'filter_id'),
256+
FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
257+
)
258+
object_type_id = ContentTypeMultipleChoiceField(
259+
label=_('Object types'),
260+
queryset=ObjectType.objects.public(),
261+
required=False
262+
)
263+
enabled = forms.NullBooleanField(
264+
label=_('Enabled'),
265+
required=False,
266+
widget=forms.Select(
267+
choices=BOOLEAN_WITH_BLANK_CHOICES
268+
)
269+
)
270+
shared = forms.NullBooleanField(
271+
label=_('Shared'),
272+
required=False,
273+
widget=forms.Select(
274+
choices=BOOLEAN_WITH_BLANK_CHOICES
275+
)
276+
)
277+
weight = forms.IntegerField(
278+
label=_('Weight'),
279+
required=False
280+
)
281+
282+
252283
class WebhookFilterForm(NetBoxModelFilterSetForm):
253284
model = Webhook
254285
fieldsets = (

netbox/extras/forms/model_forms.py

+62
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import re
33

44
from django import forms
5+
from django.contrib.postgres.forms import SimpleArrayField
56
from django.utils.safestring import mark_safe
67
from django.utils.translation import gettext_lazy as _
78

@@ -21,6 +22,7 @@
2122
)
2223
from utilities.forms.rendering import FieldSet, ObjectAttribute
2324
from utilities.forms.widgets import ChoicesWidget, HTMXSelect
25+
from utilities.tables import get_table_for_model
2426
from virtualization.models import Cluster, ClusterGroup, ClusterType
2527

2628
__all__ = (
@@ -37,6 +39,7 @@
3739
'NotificationGroupForm',
3840
'SavedFilterForm',
3941
'SubscriptionForm',
42+
'TableConfigForm',
4043
'TagForm',
4144
'WebhookForm',
4245
)
@@ -301,6 +304,65 @@ def __init__(self, *args, initial=None, **kwargs):
301304
super().__init__(*args, initial=initial, **kwargs)
302305

303306

307+
class TableConfigForm(forms.ModelForm):
308+
object_type = ContentTypeChoiceField(
309+
label=_('Object type'),
310+
queryset=ObjectType.objects.all()
311+
)
312+
ordering = SimpleArrayField(
313+
base_field=forms.CharField(),
314+
required=False,
315+
label=_('Ordering'),
316+
help_text=_(
317+
"Enter a comma-separated list of column names. Prepend a name with a hyphen to reverse the order."
318+
)
319+
)
320+
available_columns = SimpleArrayField(
321+
base_field=forms.CharField(),
322+
required=False,
323+
widget=forms.SelectMultiple(
324+
attrs={'size': 10, 'class': 'form-select'}
325+
),
326+
label=_('Available Columns')
327+
)
328+
columns = SimpleArrayField(
329+
base_field=forms.CharField(),
330+
widget=forms.SelectMultiple(
331+
attrs={'size': 10, 'class': 'form-select select-all'}
332+
),
333+
label=_('Selected Columns')
334+
)
335+
336+
class Meta:
337+
model = TableConfig
338+
exclude = ('user',)
339+
340+
def __init__(self, data=None, *args, **kwargs):
341+
super().__init__(data, *args, **kwargs)
342+
343+
object_type = ObjectType.objects.get(pk=get_field_value(self, 'object_type'))
344+
model = object_type.model_class()
345+
table_name = get_field_value(self, 'table')
346+
table_class = get_table_for_model(model, table_name)
347+
table = table_class([])
348+
349+
if columns := self._get_columns():
350+
table._set_columns(columns)
351+
352+
# Initialize columns field based on table attributes
353+
self.fields['available_columns'].widget.choices = table.available_columns
354+
self.fields['columns'].widget.choices = table.selected_columns
355+
356+
def _get_columns(self):
357+
if self.is_bound and (columns := self.data.getlist('columns')):
358+
return columns
359+
if 'columns' in self.initial:
360+
columns = self.get_initial_for_field(self.fields['columns'], 'columns')
361+
return columns.split(',') if type(columns) is str else columns
362+
if self.instance is not None:
363+
return self.instance.columns
364+
365+
304366
class BookmarkForm(forms.ModelForm):
305367
object_type = ContentTypeChoiceField(
306368
label=_('Object type'),

netbox/extras/graphql/filters.py

+14
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
'JournalEntryFilter',
3535
'NotificationGroupFilter',
3636
'SavedFilterFilter',
37+
'TableConfigFilter',
3738
'TagFilter',
3839
'WebhookFilter',
3940
)
@@ -262,6 +263,19 @@ class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
262263
)
263264

264265

266+
@strawberry_django.filter(models.TableConfig, lookups=True)
267+
class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
268+
name: FilterLookup[str] | None = strawberry_django.filter_field()
269+
description: FilterLookup[str] | None = strawberry_django.filter_field()
270+
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
271+
user_id: ID | None = strawberry_django.filter_field()
272+
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
273+
strawberry_django.filter_field()
274+
)
275+
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
276+
shared: FilterLookup[bool] | None = strawberry_django.filter_field()
277+
278+
265279
@strawberry_django.filter(models.Tag, lookups=True)
266280
class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin):
267281
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()

netbox/extras/graphql/schema.py

+3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ class ExtrasQuery:
3232
saved_filter: SavedFilterType = strawberry_django.field()
3333
saved_filter_list: List[SavedFilterType] = strawberry_django.field()
3434

35+
table_config: TableConfigType = strawberry_django.field()
36+
table_config_list: List[TableConfigType] = strawberry_django.field()
37+
3538
journal_entry: JournalEntryType = strawberry_django.field()
3639
journal_entry_list: List[JournalEntryType] = strawberry_django.field()
3740

0 commit comments

Comments
 (0)