mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-21 12:52:21 -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:
@@ -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),
|
||||
|
||||
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user