diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 5ec7b5a82..e0ef639fa 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 diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 32c40f269..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 BaseTable, ButtonsColumn, 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 = ButtonsColumn(CircuitType) 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 b8963eae7..7bfa09c21 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, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) from .template_code import * @@ -94,7 +94,6 @@ class DeviceRoleTable(BaseTable): tags = TagColumn( url_name='dcim:devicerole_list' ) - actions = ButtonsColumn(DeviceRole) 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 = ButtonsColumn(Platform) 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', ) @@ -324,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): @@ -336,7 +332,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 } @@ -369,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): @@ -381,7 +375,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 } @@ -414,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): @@ -428,7 +420,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 @@ -464,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): @@ -477,7 +466,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 @@ -557,10 +546,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): @@ -575,7 +562,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, @@ -620,10 +607,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): @@ -634,7 +619,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 @@ -669,10 +653,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): @@ -682,7 +664,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 @@ -720,10 +702,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): @@ -731,9 +711,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): @@ -758,16 +736,14 @@ 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): 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): @@ -812,10 +788,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 @@ -824,7 +797,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', ) @@ -842,14 +815,13 @@ class InventoryItemRoleTable(BaseTable): tags = TagColumn( url_name='dcim:inventoryitemrole_list' ) - actions = ButtonsColumn(InventoryItemRole) 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 525c69030..ecec67f7d 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -6,7 +6,7 @@ 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, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, ) from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS @@ -48,7 +48,6 @@ class ManufacturerTable(BaseTable): tags = TagColumn( url_name='dcim:manufacturer_list' ) - actions = ButtonsColumn(Manufacturer) class Meta(BaseTable.Meta): model = Manufacturer @@ -57,7 +56,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', ) @@ -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/racks.py b/netbox/dcim/tables/racks.py index 14bbe3589..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 ( - BaseTable, ButtonsColumn, 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 = ButtonsColumn(RackRole) 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 = ButtonsColumn(RackReservation) 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 ceca41c86..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', @@ -32,12 +32,11 @@ class RegionTable(BaseTable): tags = TagColumn( url_name='dcim:region_list' ) - actions = ButtonsColumn(Region) 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') # @@ -57,12 +56,11 @@ class SiteGroupTable(BaseTable): tags = TagColumn( url_name='dcim:sitegroup_list' ) - actions = ButtonsColumn(SiteGroup) 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') # @@ -98,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') @@ -128,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): @@ -139,4 +137,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/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/extras/tables.py b/netbox/extras/tables.py index 62317e636..071caa354 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,12 +152,11 @@ class TagTable(BaseTable): linkify=True ) color = ColorColumn() - actions = ButtonsColumn(Tag) 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(sequence=()) class Meta(BaseTable.Meta): model = ObjectChange @@ -233,9 +233,6 @@ class ObjectJournalTable(BaseTable): comments = tables.TemplateColumn( template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) - actions = ButtonsColumn( - model=JournalEntry - ) class Meta(BaseTable.Meta): model = JournalEntry @@ -261,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/fhrp.py b/netbox/ipam/tables/fhrp.py index 94bc50b93..a691b945b 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( + sequence=('edit', 'delete') ) class Meta(BaseTable.Meta): diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 3fddbf48e..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 ( - BaseTable, BooleanColumn, ButtonsColumn, 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 = ButtonsColumn(RIR) 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 = ButtonsColumn(ASN) 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 = ButtonsColumn(Role) 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,9 +401,6 @@ class AssignedIPAddressesTable(BaseTable): ) status = ChoiceFieldColumn() tenant = TenantColumn() - actions = ButtonsColumn( - model=IPAddress - ) class Meta(BaseTable.Meta): model = IPAddress diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index ca8d22552..3454ddff4 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -5,7 +5,7 @@ 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, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, TemplateColumn, ToggleColumn, ) from virtualization.models import VMInterface @@ -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): @@ -88,7 +87,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') # @@ -153,7 +152,9 @@ class VLANDevicesTable(VLANMembersTable): device = tables.Column( linkify=True ) - actions = ButtonsColumn(Interface, buttons=['edit']) + actions = ActionsColumn( + sequence=('edit',) + ) class Meta(BaseTable.Meta): model = Interface @@ -165,7 +166,9 @@ class VLANVirtualMachinesTable(VLANMembersTable): virtual_machine = tables.Column( linkify=True ) - actions = ButtonsColumn(VMInterface, buttons=['edit']) + actions = ActionsColumn( + sequence=('edit',) + ) class Meta(BaseTable.Meta): model = VMInterface 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({ diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 0ae1139bf..f15e67eab 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,12 +59,11 @@ class TenantGroupTable(BaseTable): tags = TagColumn( url_name='tenancy:tenantgroup_list' ) - actions = ButtonsColumn(TenantGroup) 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 = ButtonsColumn(ContactGroup) 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 = ButtonsColumn(ContactRole) 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): @@ -164,12 +161,11 @@ class ContactAssignmentTable(BaseTable): role = tables.Column( linkify=True ) - actions = ButtonsColumn( - model=ContactAssignment, - buttons=('edit', 'delete') + actions = ActionsColumn( + sequence=('edit', 'delete') ) 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/__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 54% rename from netbox/utilities/tables.py rename to netbox/utilities/tables/columns.py index 9000af110..a319fc7ad 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables/columns.py @@ -1,149 +1,36 @@ +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.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.template import Context, Template 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', + '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): """ @@ -205,59 +92,78 @@ class TemplateColumn(tables.TemplateColumn): return ret -class ButtonsColumn(tables.TemplateColumn): - """ - Render edit, delete, and changelog buttons for an object. +@dataclass +class ActionsItem: + title: str + icon: str + permission: Optional[str] = None - :param model: Model class to use for calculating URL view names - :param prepend_content: Additional template content to render in the column (optional) + +class ActionsColumn(tables.Column): + """ + 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 """ - 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 %}} - """ + empty_values = () + actions = { + 'edit': ActionsItem('Edit', 'pencil', 'change'), + 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), + 'changelog': ActionsItem('Changelog', 'history'), + } - 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 + def __init__(self, *args, sequence=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs): + super().__init__(*args, **kwargs) - template_code = self.template_code.format( - app_label=model._meta.app_label, - model_name=model._meta.model_name, - buttons=buttons - ) + self.extra_buttons = extra_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, - }) + # Determine which actions to enable + self.actions = { + name: self.actions[name] for name in sequence + } def header(self): return '' + def render(self, record, table, **kwargs): + # 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 + 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 '' + + 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 user.has_perm(permission): + url = reverse(f'{viewname_base}_{action}', kwargs={'pk': record.pk}) + links.append(f'
  • ' + f' {attrs.title}
  • ') + + if not links: + return '' + + menu = f'' \ + f'' \ + 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 ChoiceFieldColumn(tables.Column): """ @@ -509,34 +415,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..6c3b56959 --- /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' + ) + actions = columns.ActionsColumn() + + 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 (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): + 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 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 818b09d33..0588f51a5 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,8 +1,9 @@ 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 @@ -40,12 +41,11 @@ class ClusterTypeTable(BaseTable): tags = TagColumn( url_name='virtualization:clustertype_list' ) - actions = ButtonsColumn(ClusterType) 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 = ButtonsColumn(ClusterGroup) 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') # @@ -184,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): @@ -196,9 +194,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 4f47ee7f9..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 ( - BaseTable, ButtonsColumn, 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 = ButtonsColumn(WirelessLANGroup) 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):