diff --git a/docs/models/extras/tableconfig.md b/docs/models/extras/tableconfig.md new file mode 100644 index 000000000..e5484ec64 --- /dev/null +++ b/docs/models/extras/tableconfig.md @@ -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. diff --git a/mkdocs.yml b/mkdocs.yml index f0bd9af7a..5cab74326 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -233,6 +233,7 @@ nav: - NotificationGroup: 'models/extras/notificationgroup.md' - SavedFilter: 'models/extras/savedfilter.md' - Subscription: 'models/extras/subscription.md' + - TableConfig: 'models/extras/tableconfig.md' - Tag: 'models/extras/tag.md' - Webhook: 'models/extras/webhook.md' - IPAM: diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 5e799b504..07540c50d 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -12,4 +12,5 @@ from .serializers_.configcontexts import * from .serializers_.configtemplates import * from .serializers_.savedfilters import * from .serializers_.scripts import * +from .serializers_.tableconfigs import * from .serializers_.tags import * diff --git a/netbox/extras/api/serializers_/tableconfigs.py b/netbox/extras/api/serializers_/tableconfigs.py new file mode 100644 index 000000000..7a4fb7b2a --- /dev/null +++ b/netbox/extras/api/serializers_/tableconfigs.py @@ -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') diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 88121b640..101808753 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -14,6 +14,7 @@ router.register('custom-field-choice-sets', views.CustomFieldChoiceSetViewSet) router.register('custom-links', views.CustomLinkViewSet) router.register('export-templates', views.ExportTemplateViewSet) router.register('saved-filters', views.SavedFilterViewSet) +router.register('table-configs', views.TableConfigViewSet) router.register('bookmarks', views.BookmarkViewSet) router.register('notifications', views.NotificationViewSet) router.register('notification-groups', views.NotificationGroupViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 49a44f5f1..6e9225f73 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -131,6 +131,17 @@ class SavedFilterViewSet(NetBoxModelViewSet): 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 # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 89dd4c9f1..f03359fcf 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -32,6 +32,7 @@ __all__ = ( 'ObjectTypeFilterSet', 'SavedFilterFilterSet', 'ScriptFilterSet', + 'TableConfigFilterSet', 'TagFilterSet', 'TaggedItemFilterSet', 'WebhookFilterSet', @@ -326,6 +327,59 @@ class SavedFilterFilterSet(ChangeLoggedModelFilterSet): 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): created = django_filters.DateTimeFilter() object_type_id = MultiValueNumberFilter() diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 7a78dba8b..c854a6c81 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -21,6 +21,7 @@ __all__ = ( 'JournalEntryBulkEditForm', 'NotificationGroupBulkEditForm', 'SavedFilterBulkEditForm', + 'TableConfigBulkEditForm', 'TagBulkEditForm', 'WebhookBulkEditForm', ) @@ -201,6 +202,34 @@ class SavedFilterBulkEditForm(BulkEditForm): 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): model = Webhook diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 056ca62a5..9ea5c3a98 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -31,6 +31,7 @@ __all__ = ( 'LocalConfigContextFilterForm', 'NotificationGroupFilterForm', 'SavedFilterFilterForm', + 'TableConfigFilterForm', 'TagFilterForm', '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): model = Webhook fieldsets = ( diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 594b7d9d0..418aa6aae 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -2,6 +2,7 @@ import json import re from django import forms +from django.contrib.postgres.forms import SimpleArrayField from django.utils.safestring import mark_safe 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.widgets import ChoicesWidget, HTMXSelect +from utilities.tables import get_table_for_model from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( @@ -37,6 +39,7 @@ __all__ = ( 'NotificationGroupForm', 'SavedFilterForm', 'SubscriptionForm', + 'TableConfigForm', 'TagForm', 'WebhookForm', ) @@ -301,6 +304,65 @@ class SavedFilterForm(forms.ModelForm): 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): object_type = ContentTypeChoiceField( label=_('Object type'), diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index b8db143e4..2798c4896 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -34,6 +34,7 @@ __all__ = ( 'JournalEntryFilter', 'NotificationGroupFilter', 'SavedFilterFilter', + 'TableConfigFilter', 'TagFilter', '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) class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin): color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index 7d2d11bf1..947ff0b00 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -32,6 +32,9 @@ class ExtrasQuery: saved_filter: 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_list: List[JournalEntryType] = strawberry_django.field() diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index f4a1a397f..4bd836f6b 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -38,6 +38,7 @@ __all__ = ( 'NotificationType', 'SavedFilterType', 'SubscriptionType', + 'TableConfigType', 'TagType', 'WebhookType', ) @@ -186,6 +187,16 @@ class SubscriptionType(ObjectType): 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( models.Tag, exclude=['extras_taggeditem_items', ], diff --git a/netbox/extras/migrations/0127_tableconfig.py b/netbox/extras/migrations/0127_tableconfig.py new file mode 100644 index 000000000..e4cc30b70 --- /dev/null +++ b/netbox/extras/migrations/0127_tableconfig.py @@ -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'), + }, + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 76fad1082..d0bb5af6e 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -27,6 +27,7 @@ from utilities.html import clean_html from utilities.jinja2 import render_jinja2 from utilities.querydict import dict_to_querydict from utilities.querysets import RestrictedQuerySet +from utilities.tables import get_table_for_model __all__ = ( 'Bookmark', @@ -36,6 +37,7 @@ __all__ = ( 'ImageAttachment', 'JournalEntry', 'SavedFilter', + 'TableConfig', 'Webhook', ) @@ -524,6 +526,121 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): 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): """ An uploaded image which is associated with an object. diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 60b207058..54e3e761a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -27,6 +27,7 @@ __all__ = ( 'ReportResultsTable', 'ScriptResultsTable', 'SubscriptionTable', + 'TableConfigTable', 'TaggedItemTable', 'TagTable', '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): object_type = columns.ContentTypeColumn( verbose_name=_('Object Types'), diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 32633493f..cceb3c5a5 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -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'))), + path('table-configs/', include(get_model_urls('extras', 'tableconfig', detail=False))), + path('table-configs//', 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'))), diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 155597c30..c9f554d22 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -2,12 +2,14 @@ import importlib from django.core.exceptions import ImproperlyConfigured from django.db import models +from django.db.models import Q from taggit.managers import _TaggableManager from netbox.context import current_request from .validators import CustomValidator __all__ = ( + 'SharedObjectViewMixin', 'image_upload', 'is_report', '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: """Standardises how we generate filenames from model class for exports""" base = model._meta.verbose_name_plural.lower().replace(' ', '_') diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2833cec0d..64e3c4895 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -18,6 +18,7 @@ from dcim.models import Device, DeviceRole, Platform from extras.choices import LogLevelChoices from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class +from extras.utils import SharedObjectViewMixin from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from netbox.views.generic.mixins import TableMixin @@ -285,39 +286,22 @@ class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView): # 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) -class SavedFilterListView(SavedFilterMixin, generic.ObjectListView): +class SavedFilterListView(SharedObjectViewMixin, generic.ObjectListView): + queryset = SavedFilter.objects.all() filterset = filtersets.SavedFilterFilterSet filterset_form = forms.SavedFilterFilterForm table = tables.SavedFilterTable @register_model_view(SavedFilter) -class SavedFilterView(SavedFilterMixin, generic.ObjectView): +class SavedFilterView(SharedObjectViewMixin, generic.ObjectView): queryset = SavedFilter.objects.all() @register_model_view(SavedFilter, 'add', detail=False) @register_model_view(SavedFilter, 'edit') -class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView): +class SavedFilterEditView(SharedObjectViewMixin, generic.ObjectEditView): queryset = SavedFilter.objects.all() form = forms.SavedFilterForm @@ -328,18 +312,18 @@ class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView): @register_model_view(SavedFilter, 'delete') -class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView): +class SavedFilterDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView): queryset = SavedFilter.objects.all() @register_model_view(SavedFilter, 'bulk_import', detail=False) -class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView): +class SavedFilterBulkImportView(SharedObjectViewMixin, generic.BulkImportView): queryset = SavedFilter.objects.all() model_form = forms.SavedFilterImportForm @register_model_view(SavedFilter, 'bulk_edit', path='edit', detail=False) -class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView): +class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView): queryset = SavedFilter.objects.all() filterset = filtersets.SavedFilterFilterSet table = tables.SavedFilterTable @@ -347,12 +331,71 @@ class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView): @register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False) -class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView): +class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView): queryset = SavedFilter.objects.all() filterset = filtersets.SavedFilterFilterSet 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 # diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 778f0d67c..90a6a9910 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -349,6 +349,7 @@ CUSTOMIZATION_MENU = Menu( get_model_item('extras', 'customlink', _('Custom Links')), get_model_item('extras', 'exporttemplate', _('Export Templates')), 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', 'imageattachment', _('Image Attachments'), actions=()), ), diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 2d2c430aa..37540a92b 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -1,9 +1,9 @@ from copy import deepcopy from functools import cached_property +from urllib.parse import urlencode 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 +20,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 +58,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,25 +113,67 @@ 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. + """ + # 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: - 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 - # user's preferred ordering for this table. - ordering = request.GET.getlist(self.prefixed_order_by_field) - request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True) - 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). - self.order_by = ordering + 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 + # user's preferred ordering for this table. + ordering = request.GET.getlist(self.prefixed_order_by_field) + request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True) + 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) + + # 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 = { @@ -174,6 +182,25 @@ class BaseTable(tables.Table): } 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): """ diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 447e2a6c5..8df9f945f 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -7,7 +7,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError 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.forms import ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse @@ -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) + 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,14 @@ 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( + 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 htmx_partial(request): if request.GET.get('embedded', False): @@ -186,6 +203,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), diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index c93cdc4c4..b022e518d 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 72804c765..a8ce742fd 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/forms/elements.ts b/netbox/project-static/src/forms/elements.ts index e047ea738..a397feed6 100644 --- a/netbox/project-static/src/forms/elements.ts +++ b/netbox/project-static/src/forms/elements.ts @@ -1,6 +1,12 @@ import { getElements, scrollTo } from '../util'; function handleFormSubmit(event: Event, form: HTMLFormElement): void { + // Automatically select all options in any