From 58f7eb319faad9f135afbd86e060eb2e2ebf574e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Jan 2022 16:53:24 -0500 Subject: [PATCH 1/9] Initial work on #7679 --- netbox/circuits/tables.py | 4 +-- netbox/dcim/tables/devices.py | 8 +++--- netbox/dcim/tables/devicetypes.py | 5 ++-- netbox/dcim/tables/racks.py | 6 ++-- netbox/dcim/tables/sites.py | 7 +++-- netbox/extras/tables.py | 8 ++---- netbox/ipam/tables/fhrp.py | 7 ++--- netbox/ipam/tables/ip.py | 14 ++++----- netbox/ipam/tables/vlans.py | 12 +++++--- netbox/tenancy/tables.py | 13 ++++----- netbox/utilities/tables.py | 48 +++++++++++++++++++++++++++++++ netbox/virtualization/tables.py | 8 +++--- netbox/wireless/tables.py | 4 +-- 13 files changed, 96 insertions(+), 48 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 32c40f269..59ef073d3 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn +from utilities.tables import ActionsColumn, BaseTable, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn from .models import * @@ -88,7 +88,7 @@ class CircuitTypeTable(BaseTable): circuit_count = tables.Column( verbose_name='Circuits' ) - actions = ButtonsColumn(CircuitType) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = CircuitType diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 0c3a5f6a1..21da569a7 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -7,7 +7,7 @@ from dcim.models import ( ) from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, + ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) from .template_code import * @@ -94,7 +94,7 @@ class DeviceRoleTable(BaseTable): tags = TagColumn( url_name='dcim:devicerole_list' ) - actions = ButtonsColumn(DeviceRole) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = DeviceRole @@ -127,7 +127,7 @@ class PlatformTable(BaseTable): tags = TagColumn( url_name='dcim:platform_list' ) - actions = ButtonsColumn(Platform) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = Platform @@ -839,7 +839,7 @@ class InventoryItemRoleTable(BaseTable): tags = TagColumn( url_name='dcim:inventoryitemrole_list' ) - actions = ButtonsColumn(InventoryItemRole) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = InventoryItemRole diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 525c69030..df27a366f 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -6,7 +6,8 @@ from dcim.models import ( InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, + ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, + ToggleColumn, ) from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS @@ -48,7 +49,7 @@ class ManufacturerTable(BaseTable): tags = TagColumn( url_name='dcim:manufacturer_list' ) - actions = ButtonsColumn(Manufacturer) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = Manufacturer diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 14bbe3589..27ddb6f31 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -4,7 +4,7 @@ from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, + ActionsColumn, BaseTable, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, UtilizationColumn, ) @@ -27,7 +27,7 @@ class RackRoleTable(BaseTable): tags = TagColumn( url_name='dcim:rackrole_list' ) - actions = ButtonsColumn(RackRole) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = RackRole @@ -121,7 +121,7 @@ class RackReservationTable(BaseTable): tags = TagColumn( url_name='dcim:rackreservation_list' ) - actions = ButtonsColumn(RackReservation) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = RackReservation diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index ceca41c86..5b39e31eb 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -3,7 +3,8 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, + ActionsColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, + TagColumn, ToggleColumn, ) from .template_code import LOCATION_ELEVATIONS @@ -32,7 +33,7 @@ class RegionTable(BaseTable): tags = TagColumn( url_name='dcim:region_list' ) - actions = ButtonsColumn(Region) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = Region @@ -57,7 +58,7 @@ class SiteGroupTable(BaseTable): tags = TagColumn( url_name='dcim:sitegroup_list' ) - actions = ButtonsColumn(SiteGroup) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = SiteGroup diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 62317e636..defef465f 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django.conf import settings from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn, MarkdownColumn, ToggleColumn, ) from .models import * @@ -152,7 +152,7 @@ class TagTable(BaseTable): linkify=True ) color = ColorColumn() - actions = ButtonsColumn(Tag) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = Tag @@ -233,9 +233,7 @@ class ObjectJournalTable(BaseTable): comments = tables.TemplateColumn( template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) - actions = ButtonsColumn( - model=JournalEntry - ) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = JournalEntry diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index 94bc50b93..ce472cb1a 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -1,6 +1,6 @@ import django_tables2 as tables -from utilities.tables import BaseTable, ButtonsColumn, MarkdownColumn, TagColumn, ToggleColumn +from utilities.tables import ActionsColumn, BaseTable, MarkdownColumn, TagColumn, ToggleColumn from ipam.models import * __all__ = ( @@ -58,9 +58,8 @@ class FHRPGroupAssignmentTable(BaseTable): group = tables.Column( linkify=True ) - actions = ButtonsColumn( - model=FHRPGroupAssignment, - buttons=('edit', 'delete', 'foo') + actions = ActionsColumn( + actions=('edit', 'delete') ) class Meta(BaseTable.Meta): diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 3fddbf48e..cf81fe722 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -4,8 +4,8 @@ from django_tables2.utils import Accessor from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, - ToggleColumn, UtilizationColumn, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, + UtilizationColumn, ) from ipam.models import * @@ -89,7 +89,7 @@ class RIRTable(BaseTable): tags = TagColumn( url_name='ipam:rir_list' ) - actions = ButtonsColumn(RIR) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = RIR @@ -111,7 +111,7 @@ class ASNTable(BaseTable): url_params={'asn_id': 'pk'}, verbose_name='Sites' ) - actions = ButtonsColumn(ASN) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = ASN @@ -173,7 +173,7 @@ class RoleTable(BaseTable): tags = TagColumn( url_name='ipam:role_list' ) - actions = ButtonsColumn(Role) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = Role @@ -405,9 +405,7 @@ class AssignedIPAddressesTable(BaseTable): ) status = ChoiceFieldColumn() tenant = TenantColumn() - actions = ButtonsColumn( - model=IPAddress - ) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = IPAddress diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index ca8d22552..f1a67c698 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -5,8 +5,8 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, - TemplateColumn, ToggleColumn, + ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, + TagColumn, TemplateColumn, ToggleColumn, ) from virtualization.models import VMInterface from ipam.models import * @@ -153,7 +153,9 @@ class VLANDevicesTable(VLANMembersTable): device = tables.Column( linkify=True ) - actions = ButtonsColumn(Interface, buttons=['edit']) + actions = ActionsColumn( + actions=('edit',) + ) class Meta(BaseTable.Meta): model = Interface @@ -165,7 +167,9 @@ class VLANVirtualMachinesTable(VLANMembersTable): virtual_machine = tables.Column( linkify=True ) - actions = ButtonsColumn(VMInterface, buttons=['edit']) + actions = ActionsColumn( + actions=('edit',) + ) class Meta(BaseTable.Meta): model = VMInterface diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 0ae1139bf..b74b52528 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from utilities.tables import ( - BaseTable, ButtonsColumn, ContentTypeColumn, LinkedCountColumn, linkify_phone, MarkdownColumn, MPTTColumn, + ActionsColumn, BaseTable, ContentTypeColumn, LinkedCountColumn, linkify_phone, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ) from .models import * @@ -59,7 +59,7 @@ class TenantGroupTable(BaseTable): tags = TagColumn( url_name='tenancy:tenantgroup_list' ) - actions = ButtonsColumn(TenantGroup) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = TenantGroup @@ -103,7 +103,7 @@ class ContactGroupTable(BaseTable): tags = TagColumn( url_name='tenancy:contactgroup_list' ) - actions = ButtonsColumn(ContactGroup) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = ContactGroup @@ -116,7 +116,7 @@ class ContactRoleTable(BaseTable): name = tables.Column( linkify=True ) - actions = ButtonsColumn(ContactRole) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = ContactRole @@ -164,9 +164,8 @@ class ContactAssignmentTable(BaseTable): role = tables.Column( linkify=True ) - actions = ButtonsColumn( - model=ContactAssignment, - buttons=('edit', 'delete') + actions = ActionsColumn( + actions=('edit', 'delete') ) class Meta(BaseTable.Meta): diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 9000af110..15cbb77c8 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,3 +1,5 @@ +from collections import namedtuple + import django_tables2 as tables from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -205,6 +207,52 @@ class TemplateColumn(tables.TemplateColumn): return ret +ActionsMenuItem = namedtuple('ActionsMenuItem', ['title', 'icon']) + + +class ActionsColumn(tables.Column): + attrs = {'td': {'class': 'text-end noprint'}} + empty_values = () + _actions = { + 'edit': ActionsMenuItem('Edit', 'pencil'), + 'delete': ActionsMenuItem('Delete', 'trash-can-outline'), + 'changelog': ActionsMenuItem('Changelog', 'history'), + } + + def __init__(self, *args, actions=('edit', 'delete', 'changelog'), **kwargs): + super().__init__(*args, **kwargs) + + # Determine which actions to enable + self.actions = { + name: self._actions[name] for name in actions + } + + def header(self): + return '' + + def render(self, record, table, **kwargs): + if not self.actions: + return '' + + model = table.Meta.model + viewname_base = f'{model._meta.app_label}:{model._meta.model_name}' + request = getattr(table, 'context', {}).get('request') + url_appendix = f'?return_url={request.path}' if request else '' + + menu = '' + + return mark_safe(menu) + + class ButtonsColumn(tables.TemplateColumn): """ Render edit, delete, and changelog buttons for an object. diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 818b09d33..f04d2825e 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -2,8 +2,8 @@ import django_tables2 as tables from dcim.tables.devices import BaseInterfaceTable from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, - ToggleColumn, + ActionsColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, + TagColumn, ToggleColumn, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -40,7 +40,7 @@ class ClusterTypeTable(BaseTable): tags = TagColumn( url_name='virtualization:clustertype_list' ) - actions = ButtonsColumn(ClusterType) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = ClusterType @@ -63,7 +63,7 @@ class ClusterGroupTable(BaseTable): tags = TagColumn( url_name='virtualization:clustergroup_list' ) - actions = ButtonsColumn(ClusterGroup) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = ClusterGroup diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 4f47ee7f9..b16b31db8 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from dcim.models import Interface from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, + ActionsColumn, BaseTable, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, ) from .models import * @@ -26,7 +26,7 @@ class WirelessLANGroupTable(BaseTable): tags = TagColumn( url_name='wireless:wirelesslangroup_list' ) - actions = ButtonsColumn(WirelessLANGroup) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = WirelessLANGroup From 00a8fd654eb4eab47972c3ad7ca2b5bc6752d3e4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 09:12:48 -0500 Subject: [PATCH 2/9] Refactor table utilities --- netbox/utilities/tables/__init__.py | 30 +++ .../{tables.py => tables/columns.py} | 188 ++---------------- netbox/utilities/tables/tables.py | 138 +++++++++++++ 3 files changed, 188 insertions(+), 168 deletions(-) create mode 100644 netbox/utilities/tables/__init__.py rename netbox/utilities/{tables.py => tables/columns.py} (68%) create mode 100644 netbox/utilities/tables/tables.py diff --git a/netbox/utilities/tables/__init__.py b/netbox/utilities/tables/__init__.py new file mode 100644 index 000000000..37dd75144 --- /dev/null +++ b/netbox/utilities/tables/__init__.py @@ -0,0 +1,30 @@ +from django_tables2 import RequestConfig + +from utilities.paginator import EnhancedPaginator, get_paginate_count +from .columns import * +from .tables import * + + +# +# Pagination +# + +def paginate_table(table, request): + """ + Paginate a table given a request context. + """ + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + } + RequestConfig(request, paginate).configure(table) + + +# +# Callables +# + +def linkify_phone(value): + if value is None: + return None + return f"tel:{value}" diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables/columns.py similarity index 68% rename from netbox/utilities/tables.py rename to netbox/utilities/tables/columns.py index 15cbb77c8..177f3bd5b 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables/columns.py @@ -2,150 +2,33 @@ from collections import namedtuple 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.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist -from django.db.models.fields.related import RelatedField from django.urls import reverse from django.utils.safestring import mark_safe -from django_tables2 import RequestConfig -from django_tables2.data import TableQuerysetData from django_tables2.utils import Accessor from extras.choices import CustomFieldTypeChoices -from extras.models import CustomField, CustomLink -from .utils import content_type_identifier, content_type_name -from .paginator import EnhancedPaginator, get_paginate_count +from utilities.utils import content_type_identifier, content_type_name +__all__ = ( + 'ActionsColumn', + 'BooleanColumn', + 'ButtonsColumn', + 'ChoiceFieldColumn', + 'ColorColumn', + 'ColoredLabelColumn', + 'ContentTypeColumn', + 'ContentTypesColumn', + 'CustomFieldColumn', + 'CustomLinkColumn', + 'LinkedCountColumn', + 'MarkdownColumn', + 'MPTTColumn', + 'TagColumn', + 'TemplateColumn', + 'ToggleColumn', + 'UtilizationColumn', +) -class BaseTable(tables.Table): - """ - Default table for object lists - - :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. - """ - id = tables.Column( - linkify=True, - verbose_name='ID' - ) - - class Meta: - attrs = { - 'class': 'table table-hover object-list', - } - - def __init__(self, *args, user=None, extra_columns=None, **kwargs): - if extra_columns is None: - extra_columns = [] - - # Add custom field columns - obj_type = ContentType.objects.get_for_model(self._meta.model) - cf_columns = [ - (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) - ] - cl_columns = [ - (f'cl_{cl.name}', CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type) - ] - extra_columns.extend([*cf_columns, *cl_columns]) - - super().__init__(*args, extra_columns=extra_columns, **kwargs) - - # Set default empty_text if none was provided - if self.empty_text is None: - self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" - - # Hide non-default columns - default_columns = getattr(self.Meta, 'default_columns', list()) - if default_columns: - for column in self.columns: - if column.name not in default_columns: - self.columns.hide(column.name) - - # Apply custom column ordering for user - if user is not None and not isinstance(user, AnonymousUser): - selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") - if selected_columns: - - # Show only persistent or selected columns - for name, column in self.columns.items(): - if name in ['pk', 'actions', *selected_columns]: - self.columns.show(name) - else: - self.columns.hide(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): - - prefetch_fields = [] - for column in self.columns: - if column.visible: - model = getattr(self.Meta, 'model') - accessor = column.accessor - prefetch_path = [] - for field_name in accessor.split(accessor.SEPARATOR): - try: - field = model._meta.get_field(field_name) - except FieldDoesNotExist: - break - if isinstance(field, RelatedField): - # Follow ForeignKeys to the related model - prefetch_path.append(field_name) - model = field.remote_field.model - elif isinstance(field, GenericForeignKey): - # Can't prefetch beyond a GenericForeignKey - prefetch_path.append(field_name) - break - if prefetch_path: - prefetch_fields.append('__'.join(prefetch_path)) - self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) - - def _get_columns(self, visible=True): - columns = [] - for name, column in self.columns.items(): - if column.visible == visible and name not in ['pk', 'actions']: - columns.append((name, column.verbose_name)) - return columns - - @property - def available_columns(self): - return self._get_columns(visible=False) - - @property - def selected_columns(self): - return self._get_columns(visible=True) - - @property - def objects_count(self): - """ - Return the total number of real objects represented by the Table. This is useful when dealing with - prefixes/IP addresses/etc., where some table rows may represent available address space. - """ - if not hasattr(self, '_objects_count'): - self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) - return self._objects_count - - -# -# Table columns -# class ToggleColumn(tables.CheckBoxColumn): """ @@ -557,34 +440,3 @@ class MarkdownColumn(tables.TemplateColumn): def value(self, value): return value - - -# -# Pagination -# - -def paginate_table(table, request): - """ - Paginate a table given a request context. - """ - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(table) - - -# -# Callables -# - -def linkify_email(value): - if value is None: - return None - return f"mailto:{value}" - - -def linkify_phone(value): - if value is None: - return None - return f"tel:{value}" diff --git a/netbox/utilities/tables/tables.py b/netbox/utilities/tables/tables.py new file mode 100644 index 000000000..a18800595 --- /dev/null +++ b/netbox/utilities/tables/tables.py @@ -0,0 +1,138 @@ +import django_tables2 as tables +from django.contrib.auth.models import AnonymousUser +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldDoesNotExist +from django.db.models.fields.related import RelatedField +from django_tables2.data import TableQuerysetData + +from extras.models import CustomField, CustomLink +from . import columns + +__all__ = ( + 'BaseTable', +) + + +class BaseTable(tables.Table): + """ + Default table for object lists + + :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. + """ + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + + class Meta: + attrs = { + 'class': 'table table-hover object-list', + } + + def __init__(self, *args, user=None, extra_columns=None, **kwargs): + if extra_columns is None: + extra_columns = [] + + # Add custom field columns + obj_type = ContentType.objects.get_for_model(self._meta.model) + cf_columns = [ + (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) + ] + cl_columns = [ + (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type) + ] + extra_columns.extend([*cf_columns, *cl_columns]) + + super().__init__(*args, extra_columns=extra_columns, **kwargs) + + # Set default empty_text if none was provided + if self.empty_text is None: + self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" + + # Hide non-default columns + default_columns = getattr(self.Meta, 'default_columns', list()) + if default_columns: + for column in self.columns: + if column.name not in default_columns: + self.columns.hide(column.name) + + # Apply custom column ordering for user + if user is not None and not isinstance(user, AnonymousUser): + selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") + if selected_columns: + + # Show only persistent or selected columns + for name, column in self.columns.items(): + if name in ['pk', 'actions', *selected_columns]: + self.columns.show(name) + else: + self.columns.hide(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): + + prefetch_fields = [] + for column in self.columns: + if column.visible: + model = getattr(self.Meta, 'model') + accessor = column.accessor + prefetch_path = [] + for field_name in accessor.split(accessor.SEPARATOR): + try: + field = model._meta.get_field(field_name) + except FieldDoesNotExist: + break + if isinstance(field, RelatedField): + # Follow ForeignKeys to the related model + prefetch_path.append(field_name) + model = field.remote_field.model + elif isinstance(field, GenericForeignKey): + # Can't prefetch beyond a GenericForeignKey + prefetch_path.append(field_name) + break + if prefetch_path: + prefetch_fields.append('__'.join(prefetch_path)) + self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) + + def _get_columns(self, visible=True): + columns = [] + for name, column in self.columns.items(): + if column.visible == visible and name not in ['pk', 'actions']: + columns.append((name, column.verbose_name)) + return columns + + @property + def available_columns(self): + return self._get_columns(visible=False) + + @property + def selected_columns(self): + return self._get_columns(visible=True) + + @property + def objects_count(self): + """ + Return the total number of real objects represented by the Table. This is useful when dealing with + prefixes/IP addresses/etc., where some table rows may represent available address space. + """ + if not hasattr(self, '_objects_count'): + self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) + return self._objects_count From 6d48ce4a253f687b1b26d147da8f482a50054d5b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 10:36:58 -0500 Subject: [PATCH 3/9] Always include actions as a default column --- netbox/circuits/tables.py | 5 ++--- netbox/dcim/tables/devices.py | 29 +++++++++++------------------ netbox/dcim/tables/devicetypes.py | 3 +-- netbox/dcim/tables/racks.py | 12 ++++-------- netbox/dcim/tables/sites.py | 12 +++++------- netbox/extras/tables.py | 8 +++----- netbox/ipam/tables/ip.py | 15 +++++---------- netbox/ipam/tables/vlans.py | 2 +- netbox/tenancy/tables.py | 11 ++++------- netbox/utilities/tables/columns.py | 3 ++- netbox/utilities/tables/tables.py | 12 ++++++------ netbox/virtualization/tables.py | 15 ++++++--------- netbox/wireless/tables.py | 7 ++----- 13 files changed, 52 insertions(+), 82 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 59ef073d3..c99bec8bc 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import TenantColumn -from utilities.tables import ActionsColumn, BaseTable, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn from .models import * @@ -88,12 +88,11 @@ class CircuitTypeTable(BaseTable): circuit_count = tables.Column( verbose_name='Circuits' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = CircuitType fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug') # diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 21da569a7..f21bc3204 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -94,7 +94,6 @@ class DeviceRoleTable(BaseTable): tags = TagColumn( url_name='dcim:devicerole_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = DeviceRole @@ -102,7 +101,7 @@ class DeviceRoleTable(BaseTable): 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') + default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description') # @@ -127,7 +126,6 @@ class PlatformTable(BaseTable): tags = TagColumn( url_name='dcim:platform_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = Platform @@ -136,7 +134,7 @@ class PlatformTable(BaseTable): 'description', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', + 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', ) @@ -336,7 +334,7 @@ class DeviceConsolePortTable(ConsolePortTable): 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' ) - default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection') row_attrs = { 'class': get_cabletermination_row_class } @@ -381,7 +379,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection') row_attrs = { 'class': get_cabletermination_row_class } @@ -428,7 +426,6 @@ class DevicePowerPortTable(PowerPortTable): ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', - 'actions', ) row_attrs = { 'class': get_cabletermination_row_class @@ -477,7 +474,7 @@ class DevicePowerOutletTable(PowerOutletTable): 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', + 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', ) row_attrs = { 'class': get_cabletermination_row_class @@ -572,7 +569,7 @@ class DeviceInterfaceTable(InterfaceTable): order_by = ('name',) default_columns = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', - 'cable', 'connection', 'actions', + 'cable', 'connection', ) row_attrs = { 'class': get_interface_row_class, @@ -631,7 +628,6 @@ class DeviceFrontPortTable(FrontPortTable): ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', - 'actions', ) row_attrs = { 'class': get_cabletermination_row_class @@ -679,7 +675,7 @@ class DeviceRearPortTable(RearPortTable): 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', ) row_attrs = { 'class': get_cabletermination_row_class @@ -728,9 +724,7 @@ class DeviceDeviceBayTable(DeviceBayTable): fields = ( 'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', ) - default_columns = ( - 'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions', - ) + default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description') class ModuleBayTable(DeviceComponentTable): @@ -764,7 +758,7 @@ class DeviceModuleBayTable(ModuleBayTable): class Meta(DeviceComponentTable.Meta): model = ModuleBay fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions') - default_columns = ('pk', 'name', 'label', 'description', 'installed_module', 'actions') + default_columns = ('pk', 'name', 'label', 'description', 'installed_module') class InventoryItemTable(DeviceComponentTable): @@ -821,7 +815,7 @@ class DeviceInventoryItemTable(InventoryItemTable): 'description', 'discovered', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'actions', + 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', ) @@ -839,14 +833,13 @@ class InventoryItemRoleTable(BaseTable): tags = TagColumn( url_name='dcim:inventoryitemrole_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = InventoryItemRole fields = ( 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description', 'actions') + default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description') # diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index df27a366f..29fa4d4de 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -49,7 +49,6 @@ class ManufacturerTable(BaseTable): tags = TagColumn( url_name='dcim:manufacturer_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = Manufacturer @@ -58,7 +57,7 @@ class ManufacturerTable(BaseTable): 'actions', ) default_columns = ( - 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 27ddb6f31..565966a39 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -4,8 +4,8 @@ from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, - TagColumn, ToggleColumn, UtilizationColumn, + BaseTable, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, + ToggleColumn, UtilizationColumn, ) __all__ = ( @@ -27,12 +27,11 @@ class RackRoleTable(BaseTable): tags = TagColumn( url_name='dcim:rackrole_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = RackRole fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') + default_columns = ('pk', 'name', 'rack_count', 'color', 'description') # @@ -121,7 +120,6 @@ class RackReservationTable(BaseTable): tags = TagColumn( url_name='dcim:rackreservation_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = RackReservation @@ -129,6 +127,4 @@ class RackReservationTable(BaseTable): 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', 'actions', ) - default_columns = ( - 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', - ) + default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 5b39e31eb..23ffabae2 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -3,8 +3,7 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, - TagColumn, ToggleColumn, + BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ) from .template_code import LOCATION_ELEVATIONS @@ -33,12 +32,11 @@ class RegionTable(BaseTable): tags = TagColumn( url_name='dcim:region_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = Region fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site_count', 'description') # @@ -58,12 +56,11 @@ class SiteGroupTable(BaseTable): tags = TagColumn( url_name='dcim:sitegroup_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = SiteGroup fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site_count', 'description') # @@ -99,6 +96,7 @@ class SiteTable(BaseTable): fields = ( 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', + 'actions', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') @@ -140,4 +138,4 @@ class LocationTable(BaseTable): 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index defef465f..e30bc6907 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -152,12 +152,11 @@ class TagTable(BaseTable): linkify=True ) color = ColorColumn() - actions = ActionsColumn() class Meta(BaseTable.Meta): model = Tag fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions') - default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions') + default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') class TaggedItemTable(BaseTable): @@ -215,6 +214,7 @@ class ObjectChangeTable(BaseTable): template_code=OBJECTCHANGE_REQUEST_ID, verbose_name='Request ID' ) + actions = ActionsColumn(actions=()) class Meta(BaseTable.Meta): model = ObjectChange @@ -233,7 +233,6 @@ class ObjectJournalTable(BaseTable): comments = tables.TemplateColumn( template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = JournalEntry @@ -259,6 +258,5 @@ class JournalEntryTable(ObjectJournalTable): 'comments', 'actions' ) default_columns = ( - 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', - 'comments', 'actions' + 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments' ) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index cf81fe722..9914fb22b 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -2,12 +2,11 @@ import django_tables2 as tables from django.utils.safestring import mark_safe from django_tables2.utils import Accessor +from ipam.models import * from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, - UtilizationColumn, + BaseTable, BooleanColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, UtilizationColumn, ) -from ipam.models import * __all__ = ( 'AggregateTable', @@ -89,12 +88,11 @@ class RIRTable(BaseTable): tags = TagColumn( url_name='ipam:rir_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = RIR fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') + default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description') # @@ -111,12 +109,11 @@ class ASNTable(BaseTable): url_params={'asn_id': 'pk'}, verbose_name='Sites' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = ASN fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions') - default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions') + default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant') # @@ -173,12 +170,11 @@ class RoleTable(BaseTable): tags = TagColumn( url_name='ipam:role_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = Role fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions') - default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions') + default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description') # @@ -405,7 +401,6 @@ class AssignedIPAddressesTable(BaseTable): ) status = ChoiceFieldColumn() tenant = TenantColumn() - actions = ActionsColumn() class Meta(BaseTable.Meta): model = IPAddress diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index f1a67c698..1470b3d1a 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -88,7 +88,7 @@ class VLANGroupTable(BaseTable): 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') + default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description') # diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index b74b52528..72878ef29 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -59,12 +59,11 @@ class TenantGroupTable(BaseTable): tags = TagColumn( url_name='tenancy:tenantgroup_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = TenantGroup fields = ('pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions') + default_columns = ('pk', 'name', 'tenant_count', 'description') class TenantTable(BaseTable): @@ -103,12 +102,11 @@ class ContactGroupTable(BaseTable): tags = TagColumn( url_name='tenancy:contactgroup_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = ContactGroup fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'contact_count', 'description', 'actions') + default_columns = ('pk', 'name', 'contact_count', 'description') class ContactRoleTable(BaseTable): @@ -116,12 +114,11 @@ class ContactRoleTable(BaseTable): name = tables.Column( linkify=True ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = ContactRole fields = ('pk', 'name', 'description', 'slug', 'actions') - default_columns = ('pk', 'name', 'description', 'actions') + default_columns = ('pk', 'name', 'description') class ContactTable(BaseTable): @@ -171,4 +168,4 @@ class ContactAssignmentTable(BaseTable): class Meta(BaseTable.Meta): model = ContactAssignment fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') - default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') + default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority') diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index 177f3bd5b..468d81ab7 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -114,7 +114,8 @@ class ActionsColumn(tables.Column): return '' def render(self, record, table, **kwargs): - if not self.actions: + # Skip dummy records (e.g. available VLANs) or those with no actions + if not hasattr(record, 'pk') or not self.actions: return '' model = table.Meta.model diff --git a/netbox/utilities/tables/tables.py b/netbox/utilities/tables/tables.py index a18800595..6c3b56959 100644 --- a/netbox/utilities/tables/tables.py +++ b/netbox/utilities/tables/tables.py @@ -24,6 +24,7 @@ class BaseTable(tables.Table): linkify=True, verbose_name='ID' ) + actions = columns.ActionsColumn() class Meta: attrs = { @@ -50,12 +51,11 @@ class BaseTable(tables.Table): if self.empty_text is None: self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" - # Hide non-default columns - default_columns = getattr(self.Meta, 'default_columns', list()) - if default_columns: - for column in self.columns: - if column.name not in default_columns: - self.columns.hide(column.name) + # Hide non-default columns (except for actions) + default_columns = [*getattr(self.Meta, 'default_columns', self.Meta.fields), 'actions'] + for column in self.columns: + if column.name not in default_columns: + self.columns.hide(column.name) # Apply custom column ordering for user if user is not None and not isinstance(user, AnonymousUser): diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index f04d2825e..65f9f1257 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,9 +1,10 @@ import django_tables2 as tables + from dcim.tables.devices import BaseInterfaceTable from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, - TagColumn, ToggleColumn, + BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, + ToggleColumn, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -40,12 +41,11 @@ class ClusterTypeTable(BaseTable): tags = TagColumn( url_name='virtualization:clustertype_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = ClusterType fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') + default_columns = ('pk', 'name', 'cluster_count', 'description') # @@ -63,12 +63,11 @@ class ClusterGroupTable(BaseTable): tags = TagColumn( url_name='virtualization:clustergroup_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = ClusterGroup fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') + default_columns = ('pk', 'name', 'cluster_count', 'description') # @@ -196,9 +195,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) - default_columns = ( - 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions', - ) + default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses') row_attrs = { 'data-name': lambda record: record.name, } diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index b16b31db8..67d46f248 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,9 +1,7 @@ import django_tables2 as tables from dcim.models import Interface -from utilities.tables import ( - ActionsColumn, BaseTable, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, -) +from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn from .models import * __all__ = ( @@ -26,12 +24,11 @@ class WirelessLANGroupTable(BaseTable): tags = TagColumn( url_name='wireless:wirelesslangroup_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = WirelessLANGroup fields = ('pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'wirelesslan_count', 'description', 'actions') + default_columns = ('pk', 'name', 'wirelesslan_count', 'description') class WirelessLANTable(BaseTable): From ededa69e4ab12379e360fc1ce8ab204d6379ac42 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 10:53:00 -0500 Subject: [PATCH 4/9] Only show relevant links for user permissions --- netbox/utilities/tables/columns.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index 468d81ab7..90505f2da 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -90,16 +90,16 @@ class TemplateColumn(tables.TemplateColumn): return ret -ActionsMenuItem = namedtuple('ActionsMenuItem', ['title', 'icon']) +ActionsMenuItem = namedtuple('ActionsMenuItem', ['title', 'icon', 'permission']) class ActionsColumn(tables.Column): attrs = {'td': {'class': 'text-end noprint'}} empty_values = () _actions = { - 'edit': ActionsMenuItem('Edit', 'pencil'), - 'delete': ActionsMenuItem('Delete', 'trash-can-outline'), - 'changelog': ActionsMenuItem('Changelog', 'history'), + 'edit': ActionsMenuItem('Edit', 'pencil', 'change'), + 'delete': ActionsMenuItem('Delete', 'trash-can-outline', 'delete'), + 'changelog': ActionsMenuItem('Changelog', 'history', None), } def __init__(self, *args, actions=('edit', 'delete', 'changelog'), **kwargs): @@ -123,16 +123,21 @@ class ActionsColumn(tables.Column): request = getattr(table, 'context', {}).get('request') url_appendix = f'?return_url={request.path}' if request else '' - menu = '' + if not links: + return '' + + menu = f'' return mark_safe(menu) From 1024adca72570f58ac899850c5ca66bf782ee528 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 11:00:35 -0500 Subject: [PATCH 5/9] Exclude actions column from export --- netbox/netbox/views/generic/object_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 607501a9b..d8850391b 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -203,7 +203,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): :param table: The Table instance to export :param columns: A list of specific columns to include. If not specified, all columns will be exported. """ - exclude_columns = {'pk'} + exclude_columns = {'pk', 'actions'} if columns: all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] exclude_columns.update({ From bff7400de4503b8f25938680572e28c634f09ed4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 11:23:04 -0500 Subject: [PATCH 6/9] Convert ActionsMenuItem to dataclass --- netbox/utilities/tables/columns.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index 90505f2da..df9db357f 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -1,4 +1,6 @@ from collections import namedtuple +from dataclasses import dataclass +from typing import Optional import django_tables2 as tables from django.conf import settings @@ -90,16 +92,20 @@ class TemplateColumn(tables.TemplateColumn): return ret -ActionsMenuItem = namedtuple('ActionsMenuItem', ['title', 'icon', 'permission']) +@dataclass +class ActionsItem: + title: str + icon: str + permission: Optional[str] = None class ActionsColumn(tables.Column): attrs = {'td': {'class': 'text-end noprint'}} empty_values = () _actions = { - 'edit': ActionsMenuItem('Edit', 'pencil', 'change'), - 'delete': ActionsMenuItem('Delete', 'trash-can-outline', 'delete'), - 'changelog': ActionsMenuItem('Changelog', 'history', None), + 'edit': ActionsItem('Edit', 'pencil', 'change'), + 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), + 'changelog': ActionsItem('Changelog', 'history'), } def __init__(self, *args, actions=('edit', 'delete', 'changelog'), **kwargs): From 8b07fbc5544a7435f4178811f4927a7f606b6aaf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 11:56:18 -0500 Subject: [PATCH 7/9] Allow passing additional columns & specifying a sequence --- netbox/extras/tables.py | 2 +- netbox/ipam/tables/fhrp.py | 2 +- netbox/ipam/tables/vlans.py | 4 ++-- netbox/tenancy/tables.py | 2 +- netbox/utilities/tables/columns.py | 10 +++++++--- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index e30bc6907..071caa354 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -214,7 +214,7 @@ class ObjectChangeTable(BaseTable): template_code=OBJECTCHANGE_REQUEST_ID, verbose_name='Request ID' ) - actions = ActionsColumn(actions=()) + actions = ActionsColumn(sequence=()) class Meta(BaseTable.Meta): model = ObjectChange diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index ce472cb1a..a691b945b 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -59,7 +59,7 @@ class FHRPGroupAssignmentTable(BaseTable): linkify=True ) actions = ActionsColumn( - actions=('edit', 'delete') + sequence=('edit', 'delete') ) class Meta(BaseTable.Meta): diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 1470b3d1a..1379ad105 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -154,7 +154,7 @@ class VLANDevicesTable(VLANMembersTable): linkify=True ) actions = ActionsColumn( - actions=('edit',) + sequence=('edit',) ) class Meta(BaseTable.Meta): @@ -168,7 +168,7 @@ class VLANVirtualMachinesTable(VLANMembersTable): linkify=True ) actions = ActionsColumn( - actions=('edit',) + sequence=('edit',) ) class Meta(BaseTable.Meta): diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 72878ef29..f15e67eab 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -162,7 +162,7 @@ class ContactAssignmentTable(BaseTable): linkify=True ) actions = ActionsColumn( - actions=('edit', 'delete') + sequence=('edit', 'delete') ) class Meta(BaseTable.Meta): diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index df9db357f..e601bd0cc 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -102,18 +102,22 @@ class ActionsItem: class ActionsColumn(tables.Column): attrs = {'td': {'class': 'text-end noprint'}} empty_values = () - _actions = { + actions = { 'edit': ActionsItem('Edit', 'pencil', 'change'), 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), 'changelog': ActionsItem('Changelog', 'history'), } - def __init__(self, *args, actions=('edit', 'delete', 'changelog'), **kwargs): + def __init__(self, *args, extra_actions=None, sequence=('edit', 'delete', 'changelog'), **kwargs): super().__init__(*args, **kwargs) + # Add/update any extra actions passed + if extra_actions: + self.actions.update(extra_actions) + # Determine which actions to enable self.actions = { - name: self._actions[name] for name in actions + name: self.actions[name] for name in sequence } def header(self): From aed23d61fc752bc6a913759995ff1d57707e1602 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 11:17:40 -0500 Subject: [PATCH 8/9] Replace ButtonsColumn with ActionsColumn --- netbox/dcim/tables/devices.py | 61 ++++++------------ netbox/dcim/tables/devicetypes.py | 67 +++++++++----------- netbox/dcim/tables/sites.py | 9 ++- netbox/dcim/tables/template_code.py | 6 +- netbox/ipam/tables/vlans.py | 11 ++-- netbox/utilities/tables/columns.py | 89 ++++++++------------------- netbox/utilities/tests/test_tables.py | 3 +- netbox/virtualization/tables.py | 9 ++- 8 files changed, 90 insertions(+), 165 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f21bc3204..1241143b7 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -7,7 +7,7 @@ from dcim.models import ( ) from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) from .template_code import * @@ -322,10 +322,8 @@ class DeviceConsolePortTable(ConsolePortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=ConsolePort, - buttons=('edit', 'delete'), - prepend_template=CONSOLEPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=CONSOLEPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -367,10 +365,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=ConsoleServerPort, - buttons=('edit', 'delete'), - prepend_template=CONSOLESERVERPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=CONSOLESERVERPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -412,10 +408,8 @@ class DevicePowerPortTable(PowerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=PowerPort, - buttons=('edit', 'delete'), - prepend_template=POWERPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=POWERPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -461,10 +455,8 @@ class DevicePowerOutletTable(PowerOutletTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=PowerOutlet, - buttons=('edit', 'delete'), - prepend_template=POWEROUTLET_BUTTONS + actions = ActionsColumn( + extra_buttons=POWEROUTLET_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -551,10 +543,8 @@ class DeviceInterfaceTable(InterfaceTable): linkify=True, verbose_name='LAG' ) - actions = ButtonsColumn( - model=Interface, - buttons=('edit', 'delete'), - prepend_template=INTERFACE_BUTTONS + actions = ActionsColumn( + extra_buttons=INTERFACE_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -614,10 +604,8 @@ class DeviceFrontPortTable(FrontPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=FrontPort, - buttons=('edit', 'delete'), - prepend_template=FRONTPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=FRONTPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -662,10 +650,8 @@ class DeviceRearPortTable(RearPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=RearPort, - buttons=('edit', 'delete'), - prepend_template=REARPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=REARPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -713,10 +699,8 @@ class DeviceDeviceBayTable(DeviceBayTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=DeviceBay, - buttons=('edit', 'delete'), - prepend_template=DEVICEBAY_BUTTONS + actions = ActionsColumn( + extra_buttons=DEVICEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -749,10 +733,8 @@ class ModuleBayTable(DeviceComponentTable): class DeviceModuleBayTable(ModuleBayTable): - actions = ButtonsColumn( - model=DeviceBay, - buttons=('edit', 'delete'), - prepend_template=MODULEBAY_BUTTONS + actions = ActionsColumn( + extra_buttons=MODULEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -803,10 +785,7 @@ class DeviceInventoryItemTable(InventoryItemTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=InventoryItem, - buttons=('edit', 'delete') - ) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = InventoryItem diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 29fa4d4de..ecec67f7d 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -6,8 +6,7 @@ from dcim.models import ( InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, - ToggleColumn, + ActionsColumn, BaseTable, BooleanColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, ) from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS @@ -113,10 +112,9 @@ class ComponentTemplateTable(BaseTable): class ConsolePortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsolePortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -126,10 +124,9 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsoleServerPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -139,10 +136,9 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -152,10 +148,9 @@ class PowerPortTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerOutletTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -168,10 +163,9 @@ class InterfaceTemplateTable(ComponentTemplateTable): mgmt_only = BooleanColumn( verbose_name='Management Only' ) - actions = ButtonsColumn( - model=InterfaceTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -185,10 +179,9 @@ class FrontPortTemplateTable(ComponentTemplateTable): verbose_name='Position' ) color = ColorColumn() - actions = ButtonsColumn( - model=FrontPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -199,10 +192,9 @@ class FrontPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable): color = ColorColumn() - actions = ButtonsColumn( - model=RearPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -212,9 +204,8 @@ class RearPortTemplateTable(ComponentTemplateTable): class ModuleBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ModuleBayTemplate, - buttons=('edit', 'delete') + actions = ActionsColumn( + sequence=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -224,9 +215,8 @@ class ModuleBayTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=DeviceBayTemplate, - buttons=('edit', 'delete') + actions = ActionsColumn( + sequence=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -236,9 +226,8 @@ class DeviceBayTemplateTable(ComponentTemplateTable): class InventoryItemTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=InventoryItemTemplate, - buttons=('edit', 'delete') + actions = ActionsColumn( + sequence=('edit', 'delete') ) role = tables.Column( linkify=True diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 23ffabae2..98c5e3fd3 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -3,9 +3,9 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, + ActionsColumn, BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ) -from .template_code import LOCATION_ELEVATIONS +from .template_code import LOCATION_BUTTONS __all__ = ( 'LocationTable', @@ -127,9 +127,8 @@ class LocationTable(BaseTable): tags = TagColumn( url_name='dcim:location_list' ) - actions = ButtonsColumn( - model=Location, - prepend_template=LOCATION_ELEVATIONS + actions = ActionsColumn( + extra_buttons=LOCATION_BUTTONS ) class Meta(BaseTable.Meta): diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 2b6c02b82..a1baeb336 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -87,7 +87,7 @@ POWERFEED_CABLETERMINATION = """ {{ value }} """ -LOCATION_ELEVATIONS = """ +LOCATION_BUTTONS = """ @@ -99,8 +99,8 @@ LOCATION_ELEVATIONS = """ MODULAR_COMPONENT_TEMPLATE_BUTTONS = """ {% load helpers %} -{% if perms.dcim.add_invnetoryitemtemplate %} - +{% if perms.dcim.add_inventoryitemtemplate %} + {% endif %} diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 1379ad105..3454ddff4 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -5,8 +5,8 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, - TagColumn, TemplateColumn, ToggleColumn, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, + TemplateColumn, ToggleColumn, ) from virtualization.models import VMInterface from ipam.models import * @@ -38,7 +38,7 @@ VLAN_PREFIXES = """ {% endfor %} """ -VLANGROUP_ADD_VLAN = """ +VLANGROUP_BUTTONS = """ {% with next_vid=record.get_next_available_vid %} {% if next_vid and perms.ipam.add_vlan %} @@ -77,9 +77,8 @@ class VLANGroupTable(BaseTable): tags = TagColumn( url_name='ipam:vlangroup_list' ) - actions = ButtonsColumn( - model=VLANGroup, - prepend_template=VLANGROUP_ADD_VLAN + actions = ActionsColumn( + extra_buttons=VLANGROUP_BUTTONS ) class Meta(BaseTable.Meta): diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index e601bd0cc..a319fc7ad 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -1,9 +1,10 @@ -from collections import namedtuple from dataclasses import dataclass from typing import Optional import django_tables2 as tables from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.template import Context, Template from django.urls import reverse from django.utils.safestring import mark_safe from django_tables2.utils import Accessor @@ -14,7 +15,6 @@ from utilities.utils import content_type_identifier, content_type_name __all__ = ( 'ActionsColumn', 'BooleanColumn', - 'ButtonsColumn', 'ChoiceFieldColumn', 'ColorColumn', 'ColoredLabelColumn', @@ -100,7 +100,14 @@ class ActionsItem: class ActionsColumn(tables.Column): - attrs = {'td': {'class': 'text-end noprint'}} + """ + A dropdown menu which provides edit, delete, and changelog links for an object. Can optionally include + additional buttons rendered from a template string. + + :param sequence: The ordered list of dropdown menu items to include + :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown + """ + attrs = {'td': {'class': 'text-end text-nowrap noprint'}} empty_values = () actions = { 'edit': ActionsItem('Edit', 'pencil', 'change'), @@ -108,12 +115,10 @@ class ActionsColumn(tables.Column): 'changelog': ActionsItem('Changelog', 'history'), } - def __init__(self, *args, extra_actions=None, sequence=('edit', 'delete', 'changelog'), **kwargs): + def __init__(self, *args, sequence=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs): super().__init__(*args, **kwargs) - # Add/update any extra actions passed - if extra_actions: - self.actions.update(extra_actions) + self.extra_buttons = extra_buttons # Determine which actions to enable self.actions = { @@ -134,9 +139,10 @@ class ActionsColumn(tables.Column): url_appendix = f'?return_url={request.path}' if request else '' links = [] + user = getattr(request, 'user', AnonymousUser()) for action, attrs in self.actions.items(): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' - if attrs.permission is None or request.user.has_perm(permission): + if attrs.permission is None or user.has_perm(permission): url = reverse(f'{viewname_base}_{action}', kwargs={'pk': record.pk}) links.append(f'
  • ' f' {attrs.title}
  • ') @@ -144,68 +150,21 @@ class ActionsColumn(tables.Column): if not links: return '' - menu = f'' + f'' + + # Render any extra buttons from template code + if self.extra_buttons: + template = Template(self.extra_buttons) + context = getattr(table, "context", Context()) + context.update({'record': record}) + menu = template.render(context) + menu return mark_safe(menu) -class ButtonsColumn(tables.TemplateColumn): - """ - Render edit, delete, and changelog buttons for an object. - - :param model: Model class to use for calculating URL view names - :param prepend_content: Additional template content to render in the column (optional) - """ - buttons = ('changelog', 'edit', 'delete') - attrs = {'td': {'class': 'text-end text-nowrap noprint'}} - # Note that braces are escaped to allow for string formatting prior to template rendering - template_code = """ - {{% if "changelog" in buttons %}} - - - - {{% endif %}} - {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}} - - - - {{% endif %}} - {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}} - - - - {{% endif %}} - """ - - def __init__(self, model, *args, buttons=None, prepend_template=None, **kwargs): - if prepend_template: - prepend_template = prepend_template.replace('{', '{{') - prepend_template = prepend_template.replace('}', '}}') - self.template_code = prepend_template + self.template_code - - template_code = self.template_code.format( - app_label=model._meta.app_label, - model_name=model._meta.model_name, - buttons=buttons - ) - - super().__init__(template_code=template_code, *args, **kwargs) - - # Exclude from export by default - if 'exclude_from_export' not in kwargs: - self.exclude_from_export = True - - self.extra_context.update({ - 'buttons': buttons or self.buttons, - }) - - def header(self): - return '' - - class ChoiceFieldColumn(tables.Column): """ Render a ChoiceField value inside a indicating a particular CSS class. This is useful for displaying colored diff --git a/netbox/utilities/tests/test_tables.py b/netbox/utilities/tests/test_tables.py index 119587ff8..55a5e4cc7 100644 --- a/netbox/utilities/tests/test_tables.py +++ b/netbox/utilities/tests/test_tables.py @@ -30,7 +30,8 @@ class TagColumnTest(TestCase): def test_tagcolumn(self): template = Template('{% load render_table from django_tables2 %}{% render_table table %}') + table = TagColumnTable(Site.objects.all(), orderable=False) context = Context({ - 'table': TagColumnTable(Site.objects.all(), orderable=False) + 'table': table }) template.render(context) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 65f9f1257..0588f51a5 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -3,7 +3,7 @@ import django_tables2 as tables from dcim.tables.devices import BaseInterfaceTable from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, + ActionsColumn, BaseTable, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -183,10 +183,9 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): bridge = tables.Column( linkify=True ) - actions = ButtonsColumn( - model=VMInterface, - buttons=('edit', 'delete'), - prepend_template=VMINTERFACE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=VMINTERFACE_BUTTONS ) class Meta(BaseTable.Meta): From 94c116617a67ac7b8c72b877f7506c367f05b0f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 11:20:06 -0500 Subject: [PATCH 9/9] Changelog for #7679 --- docs/release-notes/version-3.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 6240016cf..d85ad17e2 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -57,6 +57,7 @@ Inventory item templates can be arranged hierarchically within a device type, an ### Enhancements * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation +* [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables * [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks * [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form * [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts