mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-13 16:47:34 -06:00
* 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:
parent
f8f2ad1d14
commit
bb5057c063
43
docs/models/extras/tableconfig.md
Normal file
43
docs/models/extras/tableconfig.md
Normal 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.
|
@ -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:
|
||||
|
@ -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 *
|
||||
|
22
netbox/extras/api/serializers_/tableconfigs.py
Normal file
22
netbox/extras/api/serializers_/tableconfigs.py
Normal 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')
|
@ -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)
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 = (
|
||||
|
@ -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'),
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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', ],
|
||||
|
56
netbox/extras/migrations/0127_tableconfig.py
Normal file
56
netbox/extras/migrations/0127_tableconfig.py
Normal 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'),
|
||||
},
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
@ -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'),
|
||||
|
@ -19,6 +19,9 @@ urlpatterns = [
|
||||
path('export-templates/', include(get_model_urls('extras', 'exporttemplate', detail=False))),
|
||||
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/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),
|
||||
|
||||
|
@ -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(' ', '_')
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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=()),
|
||||
),
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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),
|
||||
|
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -1,6 +1,12 @@
|
||||
import { getElements, scrollTo } from '../util';
|
||||
|
||||
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.
|
||||
const invalids = new Set<string>();
|
||||
|
||||
|
@ -1,16 +1,6 @@
|
||||
import { createToast } from './bs';
|
||||
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.
|
||||
*/
|
||||
@ -124,9 +114,6 @@ function handleSubmit(event: Event): void {
|
||||
* Initialize table configuration elements.
|
||||
*/
|
||||
export function initTableConfig(): void {
|
||||
for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
|
||||
element.addEventListener('click', saveTableConfig);
|
||||
}
|
||||
for (const element of getElements<HTMLButtonElement>('#add_columns')) {
|
||||
element.addEventListener('click', addColumns);
|
||||
}
|
||||
|
88
netbox/templates/extras/tableconfig.html
Normal file
88
netbox/templates/extras/tableconfig.html
Normal 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 %}
|
48
netbox/templates/extras/tableconfig_edit.html
Normal file
48
netbox/templates/extras/tableconfig_edit.html
Normal 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 %}
|
@ -19,6 +19,11 @@
|
||||
{# Include the updated object count for display elsewhere on the page #}
|
||||
<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 #}
|
||||
{% if actions %}
|
||||
<div class="bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">
|
||||
|
@ -26,12 +26,24 @@
|
||||
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
{% if request.user.is_authenticated and table_modal %}
|
||||
<div class="table-configure input-group">
|
||||
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}"
|
||||
data-bs-target="#{{ table_modal }}"
|
||||
class="btn">
|
||||
<div class="table-configure btn-group">
|
||||
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#{{ table_modal }}" class="btn">
|
||||
<i class="mdi mdi-cog"></i> {% trans "Configure Table" %}
|
||||
</button>
|
||||
<button type="button" class="btn dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -121,7 +121,7 @@ class TableConfigForm(forms.Form):
|
||||
choices=[],
|
||||
required=False,
|
||||
widget=forms.SelectMultiple(
|
||||
attrs={'size': 10, 'class': 'form-select'}
|
||||
attrs={'size': 10, 'class': 'form-select select-all'}
|
||||
),
|
||||
label=_('Selected Columns')
|
||||
)
|
||||
|
@ -1,13 +1,24 @@
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.registry import registry
|
||||
|
||||
__all__ = (
|
||||
'get_table_for_model',
|
||||
'get_table_ordering',
|
||||
'linkify_phone',
|
||||
'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):
|
||||
"""
|
||||
Given a request, return the prescribed table ordering, if any. This may be necessary to determine prior to rendering
|
||||
|
Loading…
Reference in New Issue
Block a user