Просмотр исходного кода

Merge pull request #8303 from netbox-community/7679-table-actions

Closes #7679: Object table actions menus
Jeremy Stretch 4 лет назад
Родитель
Сommit
17aa37ae21

+ 1 - 0
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

+ 2 - 3
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')
 
 
 #

+ 31 - 59
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')
 
 
 #

+ 29 - 40
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

+ 4 - 8
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')

+ 8 - 10
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')

+ 3 - 3
netbox/dcim/tables/template_code.py

@@ -87,7 +87,7 @@ POWERFEED_CABLETERMINATION = """
 <a href="{{ value.get_absolute_url }}">{{ value }}</a>
 """
 
-LOCATION_ELEVATIONS = """
+LOCATION_BUTTONS = """
 <a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&location_id={{ record.pk }}" class="btn btn-sm btn-primary" title="View elevations">
     <i class="mdi mdi-server"></i>
 </a>
@@ -99,8 +99,8 @@ LOCATION_ELEVATIONS = """
 
 MODULAR_COMPONENT_TEMPLATE_BUTTONS = """
 {% load helpers %}
-{% if perms.dcim.add_invnetoryitemtemplate %}
-<a href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ record.device_type.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={{ request.path }}" title="Add inventory item" class="btn btn-primary btn-sm">
+{% if perms.dcim.add_inventoryitemtemplate %}
+<a href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ record.device_type_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={{ request.path }}" title="Add inventory item" class="btn btn-primary btn-sm">
   <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
 </a>
 {% endif %}

+ 4 - 8
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'
         )

+ 3 - 4
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):

+ 5 - 12
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

+ 11 - 8
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 %}
         <a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success">
@@ -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

+ 1 - 1
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({

+ 7 - 11
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')

+ 30 - 0
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}"

+ 85 - 210
netbox/utilities/tables.py → 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
-
-
-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)
+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',
+)
 
-    @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):
+@dataclass
+class ActionsItem:
+    title: str
+    icon: str
+    permission: Optional[str] = None
+
+
+class ActionsColumn(tables.Column):
     """
-    Render edit, delete, and changelog buttons for an object.
+    A dropdown menu which provides edit, delete, and changelog links for an object. Can optionally include
+    additional buttons rendered from a template string.
 
-    :param model: Model class to use for calculating URL view names
-    :param prepend_content: Additional template content to render in the column (optional)
+    :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 %}}
-        <a href="{{% url '{app_label}:{model_name}_changelog' pk=record.pk %}}" class="btn btn-outline-dark btn-sm" title="Change log">
-            <i class="mdi mdi-history"></i>
-        </a>
-    {{% endif %}}
-    {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}}
-        <a href="{{% url '{app_label}:{model_name}_edit' pk=record.pk %}}?return_url={{{{ request.path }}}}" class="btn btn-sm btn-warning" title="Edit">
-            <i class="mdi mdi-pencil"></i>
-        </a>
-    {{% endif %}}
-    {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}}
-        <a href="{{% url '{app_label}:{model_name}_delete' pk=record.pk %}}?return_url={{{{ request.path }}}}" class="btn btn-sm btn-danger" title="Delete">
-            <i class="mdi mdi-trash-can-outline"></i>
-        </a>
-    {{% 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
-        )
+    empty_values = ()
+    actions = {
+        'edit': ActionsItem('Edit', 'pencil', 'change'),
+        'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'),
+        'changelog': ActionsItem('Changelog', 'history'),
+    }
 
-        super().__init__(template_code=template_code, *args, **kwargs)
+    def __init__(self, *args, sequence=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs):
+        super().__init__(*args, **kwargs)
 
-        # Exclude from export by default
-        if 'exclude_from_export' not in kwargs:
-            self.exclude_from_export = True
+        self.extra_buttons = extra_buttons
 
-        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'<li><a class="dropdown-item" href="{url}{url_appendix}">'
+                             f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>')
+
+        if not links:
+            return ''
+
+        menu = f'<span class="dropdown">' \
+               f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">' \
+               f'<i class="mdi mdi-wrench"></i></a>' \
+               f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
+
+        # 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}"

+ 138 - 0
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

+ 2 - 1
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)

+ 8 - 12
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,
         }

+ 2 - 5
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):