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

Merge branch 'develop' into feature

Jeremy Stretch 2 лет назад
Родитель
Сommit
914588f55d
55 измененных файлов с 509 добавлено и 1063 удалено
  1. 14 1
      CONTRIBUTING.md
  2. 12 0
      docs/development/release-checklist.md
  3. 17 0
      docs/release-notes/version-3.5.md
  4. 1 1
      netbox/circuits/views.py
  5. 2 0
      netbox/dcim/choices.py
  6. 3 0
      netbox/dcim/forms/model_forms.py
  7. 7 2
      netbox/dcim/tables/devices.py
  8. 13 0
      netbox/dcim/views.py
  9. 1 1
      netbox/extras/models/models.py
  10. 0 21
      netbox/extras/plugins/__init__.py
  11. 37 0
      netbox/extras/plugins/utils.py
  12. 3 5
      netbox/extras/reports.py
  13. 2 1
      netbox/extras/tests/test_plugins.py
  14. 3 3
      netbox/extras/tests/test_webhooks.py
  15. 16 0
      netbox/ipam/filtersets.py
  16. 19 3
      netbox/ipam/forms/bulk_import.py
  17. 6 0
      netbox/ipam/tests/test_filtersets.py
  18. 4 6
      netbox/ipam/views.py
  19. 2 9
      netbox/netbox/api/views.py
  20. 4 1
      netbox/netbox/forms/base.py
  21. 13 0
      netbox/netbox/models/features.py
  22. 0 2
      netbox/netbox/settings.py
  23. 8 5
      netbox/netbox/tables/tables.py
  24. 3 0
      netbox/netbox/views/errors.py
  25. 3 0
      netbox/netbox/views/generic/object_views.py
  26. 4 1
      netbox/templates/500.html
  27. 15 0
      netbox/templates/dcim/device/components_base.html
  28. 21 51
      netbox/templates/dcim/device/consoleports.html
  29. 21 51
      netbox/templates/dcim/device/consoleserverports.html
  30. 10 47
      netbox/templates/dcim/device/devicebays.html
  31. 21 51
      netbox/templates/dcim/device/frontports.html
  32. 22 61
      netbox/templates/dcim/device/interfaces.html
  33. 10 47
      netbox/templates/dcim/device/inventory.html
  34. 10 43
      netbox/templates/dcim/device/modulebays.html
  35. 21 51
      netbox/templates/dcim/device/poweroutlets.html
  36. 21 51
      netbox/templates/dcim/device/powerports.html
  37. 21 51
      netbox/templates/dcim/device/rearports.html
  38. 2 41
      netbox/templates/dcim/rack/non_racked_devices.html
  39. 9 40
      netbox/templates/dcim/rack/reservations.html
  40. 57 0
      netbox/templates/generic/object_children.html
  41. 2 37
      netbox/templates/ipam/aggregate/prefixes.html
  42. 0 37
      netbox/templates/ipam/asnrange/asns.html
  43. 0 19
      netbox/templates/ipam/ipaddress/ip_addresses.html
  44. 3 38
      netbox/templates/ipam/iprange/ip_addresses.html
  45. 2 37
      netbox/templates/ipam/prefix/ip_addresses.html
  46. 2 37
      netbox/templates/ipam/prefix/ip_ranges.html
  47. 2 38
      netbox/templates/ipam/prefix/prefixes.html
  48. 0 20
      netbox/templates/ipam/vlan/interfaces.html
  49. 0 20
      netbox/templates/ipam/vlan/vminterfaces.html
  50. 1 18
      netbox/templates/tenancy/object_contacts.html
  51. 11 29
      netbox/templates/virtualization/cluster/devices.html
  52. 0 36
      netbox/templates/virtualization/cluster/virtual_machines.html
  53. 10 44
      netbox/templates/virtualization/virtualmachine/interfaces.html
  54. 0 5
      netbox/tenancy/views.py
  55. 18 1
      netbox/virtualization/views.py

+ 14 - 1
CONTRIBUTING.md

@@ -14,12 +14,25 @@
 </div>
 <h3></h3>
 
-Some general tips for engaging here on GitHub:
+## :information_source: Welcome to the Stadium!
+
+In her book [Working in Public](https://www.amazon.com/Working-Public-Making-Maintenance-Software/dp/0578675862), Nadia Eghbal defines four production models for open source projects, categorized by contributor and user growth: federations, clubs, toys, and stadiums. The NetBox project fits her definition of a stadium very well:
+
+> Stadiums are projects with low contributor growth and high user growth. While they may receive casual contributions, their regular contributor base does not grow proportionately to their users. As a result, they tend to be powered by one or a few developers.
+
+The bulk of NetBox's development is carried out by a handful of core maintainers, with occasional contributions from collaborators in the community. We find the stadium analogy very useful in conveying the roles and obligations of both contributors and users.
+
+If you're a contributor, actively working on the center stage, you have an obligation to produce quality content that will benefit the project as a whole. Conversely, if you're in the audience consuming the work being produced, you have the option of making requests and suggestions, but must also recognize that contributors are under no obligation to act on them.
+
+NetBox users are welcome to participate in either role, on stage or in the crowd. We ask only that you acknowledge the role you've chosen and respect the roles of others.
+
+### General Tips for Working on GitHub
 
 * Register for a free [GitHub account](https://github.com/signup) if you haven't already.
 * You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images.
 * To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.)
 * Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue.
+* Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them.
 
 ## :bug: Reporting Bugs
 

+ 12 - 0
docs/development/release-checklist.md

@@ -43,10 +43,22 @@ Follow these instructions to perform a new installation of NetBox in a temporary
 
 Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
 
+### Rebuild Demo Data (After Release)
+
+After the release of a new minor version, generate a new demo data snapshot compatible with the new release. See the [`netbox-demo-data`](https://github.com/netbox-community/netbox-demo-data) repository for instructions.
+
 ---
 
 ## Patch Releases
 
+### Notify netbox-docker Project of Any Relevant Changes
+
+Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including:
+
+* Significant changes to `upgrade.sh`
+* Increases in minimum versions for service dependencies (PostgreSQL, Redis, etc.)
+* Any changes to the reference installation
+
 ### Update Requirements
 
 Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:

+ 17 - 0
docs/release-notes/version-3.5.md

@@ -2,6 +2,23 @@
 
 ## v3.5.8 (FUTURE)
 
+### Enhancements
+
+* [#11675](https://github.com/netbox-community/netbox/issues/11675) - Add support for specifying import/export route targets during VRF bulk import
+* [#11922](https://github.com/netbox-community/netbox/issues/11922) - Automatically populate any VDC assignments from the parent when adding a child interface via the UI
+* [#12889](https://github.com/netbox-community/netbox/issues/12889) - Add 400GE CFP2 interface type
+* [#13033](https://github.com/netbox-community/netbox/issues/13033) - Add human-friendly speed column to interfaces table
+* [#13151](https://github.com/netbox-community/netbox/issues/13151) - Add "assigned" filter for IP addresses
+* [#13368](https://github.com/netbox-community/netbox/issues/13368) - List installed plugins on the server error report page
+
+### Bug Fixes
+
+* [#12665](https://github.com/netbox-community/netbox/issues/12665) - Avoid escaping semicolons when rendering custom links
+* [#12750](https://github.com/netbox-community/netbox/issues/12750) - Automatically delete an AutoSyncRecord when its object is deleted
+* [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view
+* [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports
+* [#13414](https://github.com/netbox-community/netbox/issues/13414) - Fix support for "hide-if-unset" custom fields on bulk import forms
+
 ---
 
 ## v3.5.7 (2023-07-28)

+ 1 - 1
netbox/circuits/views.py

@@ -163,7 +163,7 @@ class ProviderNetworkView(generic.ObjectView):
         related_models = (
             (
                 Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
-                'providernetwork_id',
+                'provider_network_id',
             ),
         )
 

+ 2 - 0
netbox/dcim/choices.py

@@ -836,6 +836,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_200GE_CFP2 = '200gbase-x-cfp2'
     TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
     TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
+    TYPE_400GE_CFP2 = '400gbase-x-cfp2'
     TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
     TYPE_400GE_OSFP = '400gbase-x-osfp'
     TYPE_400GE_CDFP = '400gbase-x-cdfp'
@@ -978,6 +979,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_100GE_CFP, 'CFP (100GE)'),
                 (TYPE_100GE_CFP2, 'CFP2 (100GE)'),
                 (TYPE_200GE_CFP2, 'CFP2 (200GE)'),
+                (TYPE_400GE_CFP2, 'CFP2 (400GE)'),
                 (TYPE_100GE_CFP4, 'CFP4 (100GE)'),
                 (TYPE_100GE_CXP, 'CXP (100GE)'),
                 (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),

+ 3 - 0
netbox/dcim/forms/model_forms.py

@@ -1098,6 +1098,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         queryset=VirtualDeviceContext.objects.all(),
         required=False,
         label=_('Virtual device contexts'),
+        initial_params={
+            'interfaces': '$parent',
+        },
         query_params={
             'device_id': '$device',
         }

+ 7 - 2
netbox/dcim/tables/devices.py

@@ -591,7 +591,12 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
         }
     )
     mgmt_only = columns.BooleanColumn(
-        verbose_name=_('Management Only'),
+        verbose_name=_('Management Only')
+    )
+    speed_formatted = columns.TemplateColumn(
+        template_code='{% load helpers %}{{ value|humanize_speed }}',
+        accessor=Accessor('speed'),
+        verbose_name=_('Speed')
     )
     wireless_link = tables.Column(
         verbose_name=_('Wireless link'),
@@ -618,7 +623,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
         model = models.Interface
         fields = (
             'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
-            'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
+            'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
             'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
             'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
             'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',

+ 13 - 0
netbox/dcim/views.py

@@ -1,4 +1,5 @@
 import traceback
+from collections import defaultdict
 
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
@@ -45,6 +46,15 @@ CABLE_TERMINATION_TYPES = {
 
 
 class DeviceComponentsView(generic.ObjectChildrenView):
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+        'bulk_disconnect': {'change'},
+    })
     queryset = Device.objects.all()
 
     def get_children(self, request, parent):
@@ -1997,6 +2007,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
     table = tables.DeviceModuleBayTable
     filterset = filtersets.ModuleBayFilterSet
     template_name = 'dcim/device/modulebays.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
     tab = ViewTab(
         label=_('Module Bays'),
         badge=lambda obj: obj.module_bay_count,
@@ -2012,6 +2023,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
     table = tables.DeviceDeviceBayTable
     filterset = filtersets.DeviceBayFilterSet
     template_name = 'dcim/device/devicebays.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
     tab = ViewTab(
         label=_('Device Bays'),
         badge=lambda obj: obj.device_bay_count,
@@ -2023,6 +2035,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
 
 @register_model_view(Device, 'inventory')
 class DeviceInventoryView(DeviceComponentsView):
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
     child_model = InventoryItem
     table = tables.DeviceInventoryItemTable
     filterset = filtersets.InventoryItemFilterSet

+ 1 - 1
netbox/extras/models/models.py

@@ -316,7 +316,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         text = clean_html(text, allowed_schemes)
 
         # Sanitize link
-        link = urllib.parse.quote(link, safe='/:?&=%+[]@#,')
+        link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
 
         # Verify link scheme is allowed
         result = urllib.parse.urlparse(link)

+ 0 - 21
netbox/extras/plugins/__init__.py

@@ -2,7 +2,6 @@ import collections
 from importlib import import_module
 
 from django.apps import AppConfig
-from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.utils.module_loading import import_string
 from packaging import version
@@ -146,23 +145,3 @@ class PluginConfig(AppConfig):
         for setting, value in cls.default_settings.items():
             if setting not in user_config:
                 user_config[setting] = value
-
-
-#
-# Utilities
-#
-
-def get_plugin_config(plugin_name, parameter, default=None):
-    """
-    Return the value of the specified plugin configuration parameter.
-
-    Args:
-        plugin_name: The name of the plugin
-        parameter: The name of the configuration parameter
-        default: The value to return if the parameter is not defined (default: None)
-    """
-    try:
-        plugin_config = settings.PLUGINS_CONFIG[plugin_name]
-        return plugin_config.get(parameter, default)
-    except KeyError:
-        raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")

+ 37 - 0
netbox/extras/plugins/utils.py

@@ -0,0 +1,37 @@
+from django.apps import apps
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+
+__all__ = (
+    'get_installed_plugins',
+    'get_plugin_config',
+)
+
+
+def get_installed_plugins():
+    """
+    Return a dictionary mapping the names of installed plugins to their versions.
+    """
+    plugins = {}
+    for plugin_name in settings.PLUGINS:
+        plugin_name = plugin_name.rsplit('.', 1)[-1]
+        plugin_config = apps.get_app_config(plugin_name)
+        plugins[plugin_name] = getattr(plugin_config, 'version', None)
+
+    return dict(sorted(plugins.items()))
+
+
+def get_plugin_config(plugin_name, parameter, default=None):
+    """
+    Return the value of the specified plugin configuration parameter.
+
+    Args:
+        plugin_name: The name of the plugin
+        parameter: The name of the configuration parameter
+        default: The value to return if the parameter is not defined (default: None)
+    """
+    try:
+        plugin_config = settings.PLUGINS_CONFIG[plugin_name]
+        return plugin_config.get(parameter, default)
+    except KeyError:
+        raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")

+ 3 - 5
netbox/extras/reports.py

@@ -214,20 +214,18 @@ class Report(object):
                 self.active_test = method_name
                 test_method = getattr(self, method_name)
                 test_method()
+            job.data = self._results
             if self.failed:
                 self.logger.warning("Report failed")
-                job.status = JobStatusChoices.STATUS_FAILED
+                job.terminate(status=JobStatusChoices.STATUS_FAILED)
             else:
                 self.logger.info("Report completed successfully")
-                job.status = JobStatusChoices.STATUS_COMPLETED
+                job.terminate()
         except Exception as e:
             stacktrace = traceback.format_exc()
             self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
             logger.error(f"Exception raised during report execution: {e}")
             job.terminate(status=JobStatusChoices.STATUS_ERRORED)
-        finally:
-            job.data = self._results
-            job.terminate()
 
         # Perform any post-run tasks
         self.post_run()

+ 2 - 1
netbox/extras/tests/test_plugins.py

@@ -5,8 +5,9 @@ from django.core.exceptions import ImproperlyConfigured
 from django.test import Client, TestCase, override_settings
 from django.urls import reverse
 
-from extras.plugins import PluginMenu, get_plugin_config
+from extras.plugins import PluginMenu
 from extras.tests.dummy_plugin import config as dummy_config
+from extras.plugins.utils import get_plugin_config
 from netbox.graphql.schema import Query
 from netbox.registry import registry
 

+ 3 - 3
netbox/extras/tests/test_webhooks.py

@@ -31,8 +31,8 @@ class WebhookTest(APITestCase):
     def setUpTestData(cls):
 
         site_ct = ContentType.objects.get_for_model(Site)
-        DUMMY_URL = "http://localhost/"
-        DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
+        DUMMY_URL = 'http://localhost:9000/'
+        DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
 
         webhooks = Webhook.objects.bulk_create((
             Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
@@ -259,7 +259,7 @@ class WebhookTest(APITestCase):
             name='Conditional Webhook',
             type_create=True,
             type_update=True,
-            payload_url='http://localhost/',
+            payload_url='http://localhost:9000/',
             conditions={
                 'and': [
                     {

+ 16 - 0
netbox/ipam/filtersets.py

@@ -591,6 +591,10 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         method='_assigned_to_interface',
         label=_('Is assigned to an interface'),
     )
+    assigned = django_filters.BooleanFilter(
+        method='_assigned',
+        label=_('Is assigned'),
+    )
     status = django_filters.MultipleChoiceFilter(
         choices=IPAddressStatusChoices,
         null_value=None
@@ -706,6 +710,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
                 assigned_object_id__isnull=False
             )
 
+    def _assigned(self, queryset, name, value):
+        if value:
+            return queryset.exclude(
+                assigned_object_type__isnull=True,
+                assigned_object_id__isnull=True
+            )
+        else:
+            return queryset.filter(
+                assigned_object_type__isnull=True,
+                assigned_object_id__isnull=True
+            )
+
 
 class FHRPGroupFilterSet(NetBoxModelFilterSet):
     protocol = django_filters.MultipleChoiceFilter(

+ 19 - 3
netbox/ipam/forms/bulk_import.py

@@ -1,7 +1,6 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
-from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 
 from dcim.models import Device, Interface, Site
@@ -10,7 +9,9 @@ from ipam.constants import *
 from ipam.models import *
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
-from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
+from utilities.forms.fields import (
+    CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
+)
 from virtualization.models import VirtualMachine, VMInterface
 
 __all__ = (
@@ -42,10 +43,25 @@ class VRFImportForm(NetBoxModelImportForm):
         to_field_name='name',
         help_text=_('Assigned tenant')
     )
+    import_targets = CSVModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Import route targets')
+    )
+    export_targets = CSVModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Export route targets')
+    )
 
     class Meta:
         model = VRF
-        fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags')
+        fields = (
+            'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'comments',
+            'tags',
+        )
 
 
 class RouteTargetImportForm(NetBoxModelImportForm):

+ 6 - 0
netbox/ipam/tests/test_filtersets.py

@@ -992,6 +992,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_assigned(self):
+        params = {'assigned': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+        params = {'assigned': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
     def test_assigned_to_interface(self):
         params = {'assigned_to_interface': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)

+ 4 - 6
netbox/ipam/views.py

@@ -216,7 +216,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
     child_model = ASN
     table = tables.ASNTable
     filterset = filtersets.ASNFilterSet
-    template_name = 'ipam/asnrange/asns.html'
+    template_name = 'generic/object_children.html'
     tab = ViewTab(
         label=_('ASNs'),
         badge=lambda x: x.get_child_asns().count(),
@@ -816,7 +816,6 @@ class IPAddressAssignView(generic.ObjectView):
         table = None
 
         if form.is_valid():
-
             addresses = self.queryset.prefetch_related('vrf', 'tenant')
             # Limit to 100 results
             addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100]
@@ -866,7 +865,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
     child_model = IPAddress
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
-    template_name = 'ipam/ipaddress/ip_addresses.html'
+    template_name = 'generic/object_children.html'
     tab = ViewTab(
         label=_('Related IPs'),
         badge=lambda x: x.get_related_ips().count(),
@@ -963,7 +962,6 @@ class FHRPGroupView(generic.ObjectView):
     queryset = FHRPGroup.objects.all()
 
     def get_extra_context(self, request, instance):
-
         # Get assigned interfaces
         members_table = tables.FHRPGroupAssignmentTable(
             data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance),
@@ -1077,7 +1075,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
     child_model = Interface
     table = tables.VLANDevicesTable
     filterset = InterfaceFilterSet
-    template_name = 'ipam/vlan/interfaces.html'
+    template_name = 'generic/object_children.html'
     tab = ViewTab(
         label=_('Device Interfaces'),
         badge=lambda x: x.get_interfaces().count(),
@@ -1095,7 +1093,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
     child_model = VMInterface
     table = tables.VLANVirtualMachinesTable
     filterset = VMInterfaceFilterSet
-    template_name = 'ipam/vlan/vminterfaces.html'
+    template_name = 'generic/object_children.html'
     tab = ViewTab(
         label=_('VM Interfaces'),
         badge=lambda x: x.get_vminterfaces().count(),

+ 2 - 9
netbox/netbox/api/views.py

@@ -11,6 +11,7 @@ from rest_framework.reverse import reverse
 from rest_framework.views import APIView
 from rq.worker import Worker
 
+from extras.plugins.utils import get_installed_plugins
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 
 
@@ -61,19 +62,11 @@ class StatusView(APIView):
                 installed_apps[app_config.name] = version
         installed_apps = {k: v for k, v in sorted(installed_apps.items())}
 
-        # Gather installed plugins
-        plugins = {}
-        for plugin_name in settings.PLUGINS:
-            plugin_name = plugin_name.rsplit('.', 1)[-1]
-            plugin_config = apps.get_app_config(plugin_name)
-            plugins[plugin_name] = getattr(plugin_config, 'version', None)
-        plugins = {k: v for k, v in sorted(plugins.items())}
-
         return Response({
             'django-version': DJANGO_VERSION,
             'installed-apps': installed_apps,
             'netbox-version': settings.VERSION,
-            'plugins': plugins,
+            'plugins': get_installed_plugins(),
             'python-version': platform.python_version(),
             'rq-workers-running': Worker.count(get_connection('default')),
         })

+ 4 - 1
netbox/netbox/forms/base.py

@@ -88,7 +88,10 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
 
     def _get_custom_fields(self, content_type):
         return CustomField.objects.filter(content_types=content_type).filter(
-            ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
+            ui_visibility__in=[
+                CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
+                CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET,
+            ]
         )
 
     def _get_form_field(self, customfield):

+ 13 - 0
netbox/netbox/models/features.py

@@ -491,6 +491,19 @@ class SyncedDataMixin(models.Model):
 
         return ret
 
+    def delete(self, *args, **kwargs):
+        from core.models import AutoSyncRecord
+
+        # Delete AutoSyncRecord
+        content_type = ContentType.objects.get_for_model(self)
+        AutoSyncRecord.objects.filter(
+            datafile=self.data_file,
+            object_type=content_type,
+            object_id=self.pk
+        ).delete()
+
+        return super().delete(*args, **kwargs)
+
     def resolve_data_file(self):
         """
         Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if

+ 0 - 2
netbox/netbox/settings.py

@@ -474,8 +474,6 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
 
 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
 
-TEST_RUNNER = "django_rich.test.RichRunner"
-
 # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted
 # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter.
 EXEMPT_EXCLUDE_MODELS = (

+ 8 - 5
netbox/netbox/tables/tables.py

@@ -54,7 +54,7 @@ class BaseTable(tables.Table):
         #   3. Meta.fields
         selected_columns = None
         if user is not None and not isinstance(user, AnonymousUser):
-            selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
+            selected_columns = user.config.get(f"tables.{self.name}.columns")
         if not selected_columns:
             selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
 
@@ -113,6 +113,10 @@ class BaseTable(tables.Table):
                 columns.append((name, column.verbose_name))
         return columns
 
+    @property
+    def name(self):
+        return self.__class__.__name__
+
     @property
     def available_columns(self):
         return self._get_columns(visible=False)
@@ -138,17 +142,16 @@ class BaseTable(tables.Table):
         """
         # Save ordering preference
         if request.user.is_authenticated:
-            table_name = self.__class__.__name__
             if self.prefixed_order_by_field in request.GET:
                 if request.GET[self.prefixed_order_by_field]:
                     # If an ordering has been specified as a query parameter, save it as the
                     # user's preferred ordering for this table.
                     ordering = request.GET.getlist(self.prefixed_order_by_field)
-                    request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
+                    request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
                 else:
                     # If the ordering has been set to none (empty), clear any existing preference.
-                    request.user.config.clear(f'tables.{table_name}.ordering', commit=True)
-            elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
+                    request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
+            elif ordering := request.user.config.get(f'tables.{self.name}.ordering'):
                 # If no ordering has been specified, set the preferred ordering (if any).
                 self.order_by = ordering
 

+ 3 - 0
netbox/netbox/views/errors.py

@@ -11,6 +11,8 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
 from django.views.generic import View
 from sentry_sdk import capture_message
 
+from extras.plugins.utils import get_installed_plugins
+
 __all__ = (
     'handler_404',
     'handler_500',
@@ -53,4 +55,5 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
         'exception': str(type_),
         'netbox_version': settings.VERSION,
         'python_version': platform.python_version(),
+        'plugins': get_installed_plugins(),
     }))

+ 3 - 0
netbox/netbox/views/generic/object_views.py

@@ -143,9 +143,12 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
         return render(request, self.get_template_name(), {
             'object': instance,
             'child_model': self.child_model,
+            'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
             'table': table,
+            'table_config': f'{table.name}_config',
             'actions': actions,
             'tab': self.tab,
+            'return_url': request.get_full_path(),
             **self.get_extra_context(request, instance),
         })
 

+ 4 - 1
netbox/templates/500.html

@@ -31,7 +31,10 @@
 {{ error }}
 
 {% trans "Python version" %}: {{ python_version }}
-{% trans "NetBox version" %}: {{ netbox_version }}</pre>
+{% trans "NetBox version" %}: {{ netbox_version }}
+{% trans "Plugins" %}: {% for plugin, version in plugins.items %}
+  {{ plugin }}: {{ version }}{% empty %}{% trans "None installed" %}{% endfor %}
+</pre>
                         <p>
                             {% trans "If further assistance is required, please post to the" %} <a href="https://github.com/netbox-community/netbox/discussions">{% trans "NetBox discussion forum" %}</a> {% trans "on GitHub" %}.
                         </p>

+ 15 - 0
netbox/templates/dcim/device/components_base.html

@@ -0,0 +1,15 @@
+{% extends 'generic/object_children.html' %}
+{% load helpers %}
+
+{% block bulk_edit_controls %}
+    {{ block.super }}
+    {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
+        {% if 'bulk_rename' in actions and bulk_rename_view %}
+            <button type="submit" name="_rename"
+                    formaction="{% url bulk_rename_view %}?return_url={{ return_url }}"
+                    class="btn btn-outline-warning btn-sm">
+                <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
+            </button>
+        {% endif %}
+    {% endwith %}
+{% endblock bulk_edit_controls %}

+ 21 - 51
netbox/templates/dcim/device/consoleports.html

@@ -1,58 +1,28 @@
-{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
+{% extends 'dcim/device/components_base.html' %}
 {% load helpers %}
-{% load static %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <div class="btn-group" role="group">
-            <button type="submit" name="_edit" formaction="{% url 'dcim:consoleport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-warning btn-sm">
-              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
+{% block bulk_delete_controls %}
+    {{ block.super }}
+    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
+        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
+            <button type="submit" name="_disconnect"
+                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    class="btn btn-outline-danger btn-sm">
+                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>
-            <button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
-              <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-            </button>
-          </div>
         {% endif %}
-        <div class="btn-group" role="group">
-          {% if 'bulk_delete' in actions %}
-            <button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-danger btn-sm">
-              <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-            </button>
-          {% endif %}
-          {% if 'bulk_edit' in actions %}
-            <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleport_bulk_disconnect' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
-              <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-          {% endif %}
-        </div>
-      </div>
-      {% if perms.dcim.add_consoleport %}
+    {% endwith %}
+{% endblock bulk_delete_controls %}
+
+{% block bulk_extra_controls %}
+    {{ block.super }}
+    {% if perms.dcim.add_consoleport %}
         <div class="bulk-button-group">
-          <a href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-sm btn-primary">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Port" %}
-          </a>
+            <a href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Ports" %}
+            </a>
         </div>
-      {% endif %}
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+    {% endif %}
+{% endblock bulk_extra_controls %}

+ 21 - 51
netbox/templates/dcim/device/consoleserverports.html

@@ -1,58 +1,28 @@
-{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
+{% extends 'dcim/device/components_base.html' %}
 {% load helpers %}
-{% load static %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <div class="btn-group" role="group">
-            <button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-warning btn-sm">
-              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
+{% block bulk_delete_controls %}
+    {{ block.super }}
+    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
+        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
+            <button type="submit" name="_disconnect"
+                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    class="btn btn-outline-danger btn-sm">
+                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>
-            <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
-              <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-            </button>
-          </div>
         {% endif %}
-        <div class="btn-group" role="group">
-          {% if 'bulk_delete' in actions %}
-            <button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-danger btn-sm">
-              <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-            </button>
-          {% endif %}
-          {% if 'bulk_edit' in actions %}
-            <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
-              <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-          {% endif %}
-        </div>
-      </div>
-      {% if perms.dcim.add_consoleserverport %}
+    {% endwith %}
+{% endblock bulk_delete_controls %}
+
+{% block bulk_extra_controls %}
+    {{ block.super }}
+    {% if perms.dcim.add_consoleserverport %}
         <div class="bulk-button-group">
-          <a href="{% url 'dcim:consoleserverport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-primary btn-sm">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Server Ports" %}
-          </a>
+            <a href="{% url 'dcim:consoleserverport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Server Ports" %}
+            </a>
         </div>
-      {% endif %}
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+    {% endif %}
+{% endblock bulk_extra_controls %}

+ 10 - 47
netbox/templates/dcim/device/devicebays.html

@@ -1,51 +1,14 @@
-{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
-{% load helpers %}
-{% load static %}
+{% extends 'dcim/device/components_base.html' %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <div class="btn-group" role="group">
-            <button type="submit" name="_edit" formaction="{% url 'dcim:devicebay_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-warning btn-sm">
-              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-            </button>
-            <button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
-              <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-            </button>
-          </div>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" name="_delete" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-      {% if perms.dcim.add_devicebay %}
+{% block bulk_extra_controls %}
+    {{ block.super }}
+    {% if perms.dcim.add_devicebay %}
         <div class="bulk-button-group">
-          <a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-primary btn-sm">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Device Bays" %}
-          </a>
+            <a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Device Bays" %}
+            </a>
         </div>
-      {% endif %}
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+    {% endif %}
+{% endblock bulk_extra_controls %}

+ 21 - 51
netbox/templates/dcim/device/frontports.html

@@ -1,58 +1,28 @@
-{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
+{% extends 'dcim/device/components_base.html' %}
 {% load helpers %}
-{% load static %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <div class="btn-group" role="group">
-            <button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-warning btn-sm">
-              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
+{% block bulk_delete_controls %}
+    {{ block.super }}
+    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
+        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
+            <button type="submit" name="_disconnect"
+                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    class="btn btn-outline-danger btn-sm">
+                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>
-            <button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
-              <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-            </button>
-          </div>
         {% endif %}
-        <div class="btn-group" role="group">
-          {% if 'bulk_delete' in actions %}
-            <button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-danger btn-sm">
-              <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-            </button>
-          {% endif %}
-          {% if 'bulk_edit' in actions %}
-            <button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
-              <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-          {% endif %}
-        </div>
-      </div>
-      {% if perms.dcim.add_frontport %}
+    {% endwith %}
+{% endblock bulk_delete_controls %}
+
+{% block bulk_extra_controls %}
+    {{ block.super }}
+    {% if perms.dcim.add_frontport %}
         <div class="bulk-button-group">
-          <a href="{% url 'dcim:frontport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-primary btn-sm">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add front ports" %}
-          </a>
+            <a href="{% url 'dcim:frontport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Front Ports" %}
+            </a>
         </div>
-      {% endif %}
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+    {% endif %}
+{% endblock bulk_extra_controls %}

+ 22 - 61
netbox/templates/dcim/device/interfaces.html

@@ -1,67 +1,28 @@
-{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
+{% extends 'dcim/device/components_base.html' %}
 {% load helpers %}
-{% load static %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %}
-
-<form method="post">
-  {% csrf_token %}
-
-  <div class="card">
-    <div class="card-body htmx-container table-responsive" id="object_list">
-      {% include 'htmx/table.html' %}
-    </div>
-  </div>
-
-  <div class="noprint bulk-buttons">
-    <div class="bulk-button-group">
-      {% if 'bulk_edit' in actions %}
-        <div class="btn-group" role="group">
-          <button type="submit" name="_edit"
-            formaction="{% url 'dcim:interface_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
-            class="btn btn-warning btn-sm">
-            <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-          </button>
-          <button type="submit" name="_rename"
-            formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
-            class="btn btn-outline-warning btn-sm">
-            <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-          </button>
-        </div>
-      {% endif %}
-      <div class="btn-group" role="group">
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" name="_delete"
-            formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
-            class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
+{% block bulk_delete_controls %}
+    {{ block.super }}
+    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
+        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
+            <button type="submit" name="_disconnect"
+                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    class="btn btn-outline-danger btn-sm">
+                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
+            </button>
         {% endif %}
-        {% if 'bulk_edit' in actions %}
-          <button type="submit" name="_disconnect"
-            formaction="{% url 'dcim:interface_bulk_disconnect' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
-            class="btn btn-outline-danger btn-sm">
-            <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-          </button>
-        {% endif %}
-      </div>
-    </div>
+    {% endwith %}
+{% endblock bulk_delete_controls %}
+
+{% block bulk_extra_controls %}
+    {{ block.super }}
     {% if perms.dcim.add_interface %}
-      <div class="bulk-button-group">
-        <a href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
-          class="btn btn-primary btn-sm">
-          <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Interfaces" %}
-        </a>
-      </div>
+        <div class="bulk-button-group">
+            <a href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Interfaces" %}
+            </a>
+        </div>
     {% endif %}
-  </div>
-</form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+{% endblock bulk_extra_controls %}

+ 10 - 47
netbox/templates/dcim/device/inventory.html

@@ -1,51 +1,14 @@
-{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
-{% load helpers %}
-{% load static %}
+{% extends 'dcim/device/components_base.html' %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <div class="btn-group" role="group">
-            <button type="submit" name="_edit" formaction="{% url 'dcim:inventoryitem_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-warning btn-sm">
-              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-            </button>
-            <button type="submit" name="_rename" formaction="{% url 'dcim:inventoryitem_bulk_rename' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
-              <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-            </button>
-          </div>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" name="_delete" formaction="{% url 'dcim:inventoryitem_bulk_delete' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-      {% if perms.dcim.add_inventoryitem %}
+{% block bulk_extra_controls %}
+    {{ block.super }}
+    {% if perms.dcim.add_inventoryitem %}
         <div class="bulk-button-group">
-          <a href="{% url 'dcim:inventoryitem_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-primary btn-sm">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Inventory Item" %}
-          </a>
+            <a href="{% url 'dcim:inventoryitem_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Inventory Item" %}
+            </a>
         </div>
-      {% endif %}
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+    {% endif %}
+{% endblock bulk_extra_controls %}

+ 10 - 43
netbox/templates/dcim/device/modulebays.html

@@ -1,47 +1,14 @@
-{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
-{% load helpers %}
-{% load static %}
+{% extends 'dcim/device/components_base.html' %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <div class="btn-group" role="group">
-            <button type="submit" name="_edit" formaction="{% url 'dcim:modulebay_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-warning btn-sm">
-              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-            </button>
-            <button type="submit" name="_rename" formaction="{% url 'dcim:modulebay_bulk_rename' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
-              <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-            </button>
-          </div>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" formaction="{% url 'dcim:modulebay_bulk_delete' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-      {% if perms.dcim.add_modulebay %}
+{% block bulk_extra_controls %}
+    {{ block.super }}
+    {% if perms.dcim.add_modulebay %}
         <div class="bulk-button-group">
-          <a href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-primary btn-sm">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Module Bays" %}
-          </a>
+            <a href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Module Bays" %}
+            </a>
         </div>
-      {% endif %}
-    </div>
-  </form>
-  {% table_config_form table %}
-{% endblock %}
+    {% endif %}
+{% endblock bulk_extra_controls %}

+ 21 - 51
netbox/templates/dcim/device/poweroutlets.html

@@ -1,58 +1,28 @@
-{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
+{% extends 'dcim/device/components_base.html' %}
 {% load helpers %}
-{% load static %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <div class="btn-group" role="group">
-            <button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-warning btn-sm">
-              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
+{% block bulk_delete_controls %}
+    {{ block.super }}
+    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
+        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
+            <button type="submit" name="_disconnect"
+                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    class="btn btn-outline-danger btn-sm">
+                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>
-            <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
-              <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-            </button>
-          </div>
         {% endif %}
-        <div class="btn-group" role="group">
-          {% if 'bulk_delete' in actions %}
-            <button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-danger btn-sm">
-              <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-            </button>
-          {% endif %}
-          {% if 'bulk_edit' in actions %}
-            <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
-              <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-          {% endif %}
-        </div>
-      </div>
-      {% if perms.dcim.add_poweroutlet %}
+    {% endwith %}
+{% endblock bulk_delete_controls %}
+
+{% block bulk_extra_controls %}
+    {{ block.super }}
+    {% if perms.dcim.add_poweroutlet %}
         <div class="bulk-button-group">
-          <a href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-primary btn-sm">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Outlets" %}
-          </a>
+            <a href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Outlets" %}
+            </a>
         </div>
-      {% endif %}
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+    {% endif %}
+{% endblock bulk_extra_controls %}

+ 21 - 51
netbox/templates/dcim/device/powerports.html

@@ -1,58 +1,28 @@
-{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
+{% extends 'dcim/device/components_base.html' %}
 {% load helpers %}
-{% load static %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <div class="btn-group" role="group">
-            <button type="submit" name="_edit" formaction="{% url 'dcim:powerport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-warning btn-sm">
-              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
+{% block bulk_delete_controls %}
+    {{ block.super }}
+    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
+        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
+            <button type="submit" name="_disconnect"
+                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    class="btn btn-outline-danger btn-sm">
+                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>
-            <button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
-              <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-            </button>
-          </div>
         {% endif %}
-        <div class="btn-group" role="group">
-          {% if 'bulk_delete' in actions %}
-            <button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-danger btn-sm">
-              <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-            </button>
-          {% endif %}
-          {% if 'bulk_edit' in actions %}
-            <button type="submit" name="_disconnect" formaction="{% url 'dcim:powerport_bulk_disconnect' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
-              <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-          {% endif %}
-        </div>
-      </div>
-      {% if perms.dcim.add_powerport %}
+    {% endwith %}
+{% endblock bulk_delete_controls %}
+
+{% block bulk_extra_controls %}
+    {{ block.super }}
+    {% if perms.dcim.add_powerport %}
         <div class="bulk-button-group">
-          <a href="{% url 'dcim:powerport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-sm btn-primary">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Port" %}
-          </a>
+            <a href="{% url 'dcim:powerport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Port" %}
+            </a>
         </div>
-      {% endif %}
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+    {% endif %}
+{% endblock bulk_extra_controls %}

+ 21 - 51
netbox/templates/dcim/device/rearports.html

@@ -1,58 +1,28 @@
-{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
-{% load static %}
+{% extends 'dcim/device/components_base.html' %}
 {% load helpers %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <div class="btn-group" role="group">
-            <button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-warning btn-sm">
-              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
+{% block bulk_delete_controls %}
+    {{ block.super }}
+    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
+        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
+            <button type="submit" name="_disconnect"
+                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    class="btn btn-outline-danger btn-sm">
+                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>
-            <button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
-              <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-            </button>
-          </div>
         {% endif %}
-        <div class="btn-group" role="group">
-          {% if 'bulk_delete' in actions %}
-            <button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-danger btn-sm">
-              <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-            </button>
-          {% endif %}
-          {% if 'bulk_edit' in actions %}
-            <button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
-              <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-          {% endif %}
-        </div>
-      </div>
-      {% if perms.dcim.add_rearport %}
+    {% endwith %}
+{% endblock bulk_delete_controls %}
+
+{% block bulk_extra_controls %}
+    {{ block.super }}
+    {% if perms.dcim.add_rearport %}
         <div class="bulk-button-group">
-          <a href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-primary btn-sm">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add rear ports" %}
-          </a>
+            <a href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Rear Ports" %}
+            </a>
         </div>
-      {% endif %}
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+    {% endif %}
+{% endblock bulk_extra_controls %}

+ 2 - 41
netbox/templates/dcim/rack/non_racked_devices.html

@@ -1,5 +1,4 @@
-{% extends 'dcim/rack/base.html' %}
-{% load helpers %}
+{% extends 'generic/object_children.html' %}
 
 {% block extra_controls %}
     {% if perms.dcim.add_device %}
@@ -10,42 +9,4 @@
             </a>
         </div>
     {% endif %}
-{% endblock %}
-
-{% block content %}
-    {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %}
-
-    <form method="post">
-        {% csrf_token %}
-
-        <div class="card">
-            <div class="card-body htmx-container table-responsive" id="object_list">
-                {% include 'htmx/table.html' %}
-            </div>
-        </div>
-
-        <div class="noprint bulk-buttons">
-            <div class="bulk-button-group">
-                {% if 'bulk_edit' in actions %}
-                    <button type="submit" name="_edit"
-                            formaction="{% url 'dcim:device_bulk_edit' %}?return_url={% url 'dcim:rack_nonracked_devices' pk=object.pk %}"
-                            class="btn btn-warning btn-sm">
-                        <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
-                    </button>
-                {% endif %}
-                {% if 'bulk_delete' in actions %}
-                    <button type="submit"
-                            formaction="{% url 'dcim:device_bulk_delete' %}?return_url={% url 'dcim:rack_nonracked_devices' pk=object.pk %}"
-                            class="btn btn-danger btn-sm">
-                        <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
-                    </button>
-                {% endif %}
-            </div>
-        </div>
-    </form>
-{% endblock content %}
-
-{% block modals %}
-    {{ block.super }}
-    {% table_config_form table %}
-{% endblock modals %}
+{% endblock extra_controls %}

+ 9 - 40
netbox/templates/dcim/rack/reservations.html

@@ -1,44 +1,13 @@
-{% extends 'dcim/rack/base.html' %}
-{% load helpers %}
+{% extends 'generic/object_children.html' %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="RackReservationTable_config" %}
-
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <button type="submit" name="_edit" formaction="{% url 'dcim:rackreservation_bulk_edit' %}?return_url={% url 'dcim:rack_reservations' pk=object.pk %}" class="btn btn-warning btn-sm">
-            <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-          </button>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" formaction="{% url 'dcim:rackreservation_bulk_delete' %}?return_url={% url 'dcim:rack_reservations' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-      {% if perms.dcim.add_rackreservation %}
+{% block extra_controls %}
+    {% if perms.dcim.add_rackreservation %}
         <div class="bulk-button-group">
-          <a href="{% url 'dcim:rackreservation_add' %}?rack={{ object.pk }}&return_url={% url 'dcim:rack_reservations' pk=object.pk %}" class="btn btn-primary btn-sm">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add reservation" %}
-          </a>
+            <a href="{% url 'dcim:rackreservation_add' %}?rack={{ object.pk }}&return_url={% url 'dcim:rack_reservations' pk=object.pk %}"
+               class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add reservation" %}
+            </a>
         </div>
-      {% endif %}
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+    {% endif %}
+{% endblock extra_controls %}

+ 57 - 0
netbox/templates/generic/object_children.html

@@ -0,0 +1,57 @@
+{% extends base_template %}
+{% load helpers %}
+
+{% block content %}
+    {% include 'inc/table_controls_htmx.html' with table_modal=table_config %}
+    <form method="post">
+        {% csrf_token %}
+        <div class="card">
+            <div class="card-body htmx-container table-responsive" id="object_list">
+                {% include 'htmx/table.html' %}
+            </div>
+        </div>
+        <div class="noprint bulk-buttons">
+            {% block bulk_controls %}
+                <div class="bulk-button-group">
+                    <div class="btn-group" role="group">
+                        {# Bulk edit buttons #}
+                        {% block bulk_edit_controls %}
+                            {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
+                                {% if 'bulk_edit' in actions and bulk_edit_view %}
+                                    <button type="submit" name="_edit"
+                                            formaction="{% url bulk_edit_view %}?return_url={{ return_url }}"
+                                            class="btn btn-warning btn-sm">
+                                        <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
+                                    </button>
+                                {% endif %}
+                            {% endwith %}
+                        {% endblock bulk_edit_controls %}
+                    </div>
+                    <div class="btn-group" role="group">
+                        {# Bulk delete buttons #}
+                        {% block bulk_delete_controls %}
+                            {% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %}
+                                {% if 'bulk_delete' in actions and bulk_delete_view %}
+                                    <button type="submit"
+                                            formaction="{% url bulk_delete_view %}?return_url={{ return_url }}"
+                                            class="btn btn-danger btn-sm">
+                                        <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete Selected
+                                    </button>
+                                {% endif %}
+                            {% endwith %}
+                        {% endblock bulk_delete_controls %}
+                    </div>
+                </div>
+                <div class="bulk-button-group">
+                    {# Other bulk action buttons #}
+                    {% block bulk_extra_controls %}{% endblock %}
+                </div>
+            {% endblock bulk_controls %}
+        </div>
+    </form>
+{% endblock content %}
+
+{% block modals %}
+    {{ block.super }}
+    {% table_config_form table %}
+{% endblock modals %}

+ 2 - 37
netbox/templates/ipam/aggregate/prefixes.html

@@ -1,5 +1,4 @@
-{% extends 'ipam/aggregate/base.html' %}
-{% load helpers %}
+{% extends 'generic/object_children.html' %}
 {% load i18n %}
 
 {% block extra_controls %}
@@ -10,38 +9,4 @@
     </a>
   {% endif %}
   {{ block.super }}
-{% endblock %}
-
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
-            <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-          </button>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+{% endblock extra_controls %}

+ 0 - 37
netbox/templates/ipam/asnrange/asns.html

@@ -1,37 +0,0 @@
-{% extends 'ipam/asnrange/base.html' %}
-{% load helpers %}
-{% load i18n %}
-
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="ASNTable_config" %}
-
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <button type="submit" name="_edit" formaction="{% url 'ipam:asn_bulk_edit' %}?return_url={% url 'ipam:asnrange_asns' pk=object.pk %}" class="btn btn-warning btn-sm">
-            <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-          </button>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" name="_delete" formaction="{% url 'ipam:asn_bulk_delete' %}?return_url={% url 'ipam:asnrange_asns' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}

+ 0 - 19
netbox/templates/ipam/ipaddress/ip_addresses.html

@@ -1,19 +0,0 @@
-{% extends 'ipam/ipaddress/base.html' %}
-{% load helpers %}
-
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
-  <form method="post">
-    {% csrf_token %}
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}

+ 3 - 38
netbox/templates/ipam/iprange/ip_addresses.html

@@ -1,45 +1,10 @@
-{% extends 'ipam/iprange/base.html' %}
-{% load helpers %}
+{% extends 'generic/object_children.html' %}
 {% load i18n %}
 
 {% block extra_controls %}
-  {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and object.first_available_ip %}
+  {% if perms.ipam.add_ipaddress and object.first_available_ip %}
     <a href="{% url 'ipam:ipaddress_add' %}?address={{ object.first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-primary">
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add IP Address" %}
     </a>
   {% endif %}
-{% endblock %}
-
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
-            <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-          </button>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+{% endblock extra_controls %}

+ 2 - 37
netbox/templates/ipam/prefix/ip_addresses.html

@@ -1,5 +1,4 @@
-{% extends 'ipam/prefix/base.html' %}
-{% load helpers %}
+{% extends 'generic/object_children.html' %}
 {% load i18n %}
 
 {% block extra_controls %}
@@ -8,38 +7,4 @@
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add IP Address" %}
     </a>
   {% endif %}
-{% endblock %}
-
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
-            <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-          </button>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+{% endblock extra_controls %}

+ 2 - 37
netbox/templates/ipam/prefix/ip_ranges.html

@@ -1,5 +1,4 @@
-{% extends 'ipam/prefix/base.html' %}
-{% load helpers %}
+{% extends 'generic/object_children.html' %}
 {% load i18n %}
 
 {% block extra_controls %}
@@ -8,38 +7,4 @@
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add IP Range" %}
     </a>
   {% endif %}
-{% endblock %}
-
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="IPRangeTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <button type="submit" name="_edit" formaction="{% url 'ipam:iprange_bulk_edit' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-warning btn-sm">
-            <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-          </button>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" name="_delete" formaction="{% url 'ipam:iprange_bulk_delete' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+{% endblock extra_controls %}

+ 2 - 38
netbox/templates/ipam/prefix/prefixes.html

@@ -1,5 +1,4 @@
-{% extends 'ipam/prefix/base.html' %}
-{% load helpers %}
+{% extends 'generic/object_children.html' %}
 {% load i18n %}
 
 {% block extra_controls %}
@@ -9,39 +8,4 @@
       <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}
     </a>
   {% endif %}
-  {{ block.super }}
-{% endblock %}
-
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
-            <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-          </button>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-    </div>
-  </form>
-{% endblock %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+{% endblock extra_controls %}

+ 0 - 20
netbox/templates/ipam/vlan/interfaces.html

@@ -1,20 +0,0 @@
-{% extends 'ipam/vlan/base.html' %}
-{% load helpers %}
-
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-  </form>
-{% endblock content %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}

+ 0 - 20
netbox/templates/ipam/vlan/vminterfaces.html

@@ -1,20 +0,0 @@
-{% extends 'ipam/vlan/base.html' %}
-{% load helpers %}
-
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-  </form>
-{% endblock content %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}

+ 1 - 18
netbox/templates/tenancy/object_contacts.html

@@ -1,4 +1,4 @@
-{% extends base_template %}
+{% extends 'generic/object_children.html' %}
 {% load helpers %}
 {% load i18n %}
 
@@ -11,20 +11,3 @@
     {% endwith %}
   {% endif %}
 {% endblock %}
-
-{% block content %}
-    {% include 'inc/table_controls_htmx.html' with table_modal="ContactAssignmentTable_config" %}
-    <form method="post">
-        {% csrf_token %}
-        <div class="card">
-            <div class="card-body" id="object_list">
-                {% include 'htmx/table.html' %}
-            </div>
-        </div>
-    </form>
-{% endblock content %}
-
-{% block modals %}
-    {{ block.super }}
-    {% table_config_form table %}
-{% endblock modals %}

+ 11 - 29
netbox/templates/virtualization/cluster/devices.html

@@ -1,31 +1,13 @@
-{% extends 'virtualization/cluster/base.html' %}
-{% load helpers %}
-{% load render_table from django_tables2 %}
+{% extends 'generic/object_children.html' %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %}
-  
-  <form action="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}" method="post">
-    {% csrf_token %}
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if perms.virtualization.change_cluster %}
-          <button type="submit" name="_remove" class="btn btn-danger btn-sm">
-            <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Remove Devices" %}
-          </button>
-        {% endif %}
-      </div>
-    </div>
-  </form>
-{% endblock content %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+{% block bulk_delete_controls %}
+    {{ block.super }}
+    {% if 'bulk_remove_devices' in actions %}
+        <button type="submit" name="_remove"
+                formaction="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}?return_url={{ return_url }}"
+                class="btn btn-danger btn-sm">
+            <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Remove Selected" %}
+        </button>
+    {% endif %}
+{% endblock bulk_delete_controls %}

+ 0 - 36
netbox/templates/virtualization/cluster/virtual_machines.html

@@ -1,36 +0,0 @@
-{% extends 'virtualization/cluster/base.html' %}
-{% load helpers %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
-
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-    <div class="noprint bulk-buttons">
-      <div class="bulk-button-group">
-        {% if 'bulk_edit' in actions %}
-          <button type="submit" name="_edit" formaction="{% url 'virtualization:virtualmachine_bulk_edit' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-warning btn-sm">
-            <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-          </button>
-        {% endif %}
-        {% if 'bulk_delete' in actions %}
-          <button type="submit" name="_delete" formaction="{% url 'virtualization:virtualmachine_bulk_delete' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </button>
-        {% endif %}
-      </div>
-    </div>
-  </form>
-{% endblock content %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}

+ 10 - 44
netbox/templates/virtualization/virtualmachine/interfaces.html

@@ -1,48 +1,14 @@
-{% extends 'virtualization/virtualmachine/base.html' %}
-{% load render_table from django_tables2 %}
+{% extends 'generic/object_children.html' %}
 {% load helpers %}
 {% load i18n %}
 
-{% block content %}
-  {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineVMInterfaceTable_config" %}
-  
-  <form method="post">
-    {% csrf_token %}
-
-    <div class="card">
-      <div class="card-body htmx-container table-responsive" id="object_list">
-        {% include 'htmx/table.html' %}
-      </div>
-    </div>
-
-    <div class="noprint">
-      {% if perms.virtualization.change_vminterface %}
-        <div class="btn-group" role="group">
-          <button type="submit" name="_edit" formaction="{% url 'virtualization:vminterface_bulk_edit' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm">
-            <span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
-          </button>
-          <button type="submit" name="_rename" formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
-            <span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Rename" %}
-          </button>
-        </div>
-      {% endif %}
-      {% if perms.virtualization.delete_vminterface %}
-        <button type="submit" name="_delete" formaction="{% url 'virtualization:vminterface_bulk_delete' %}?return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-danger btn-sm">
-          <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Delete" %}
+{% block bulk_edit_controls %}
+    {{ block.super }}
+    {% if 'bulk_rename' in actions %}
+        <button type="submit" name="_rename"
+                formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={{ return_url }}"
+                class="btn btn-outline-warning btn-sm">
+            <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
         </button>
-      {% endif %}
-      {% if perms.virtualization.add_vminterface %}
-        <div class="float-end">
-          <a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-primary btn-sm">
-            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add interfaces" %}
-          </a>
-        </div>
-      {% endif %}
-     </div>
-  </form>
-{% endblock content %}
-
-{% block modals %}
-  {{ block.super }}
-  {% table_config_form table %}
-{% endblock modals %}
+    {% endif %}
+{% endblock bulk_edit_controls %}

+ 0 - 5
netbox/tenancy/views.py

@@ -41,11 +41,6 @@ class ObjectContactsView(generic.ObjectChildrenView):
 
         return table
 
-    def get_extra_context(self, request, instance):
-        return {
-            'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
-        }
-
 #
 # Tenant groups
 #

+ 18 - 1
netbox/virtualization/views.py

@@ -1,3 +1,5 @@
+from collections import defaultdict
+
 from django.contrib import messages
 from django.db import transaction
 from django.db.models import Prefetch, Sum
@@ -175,7 +177,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
     child_model = VirtualMachine
     table = tables.VirtualMachineTable
     filterset = filtersets.VirtualMachineFilterSet
-    template_name = 'virtualization/cluster/virtual_machines.html'
+    template_name = 'generic/object_children.html'
     tab = ViewTab(
         label=_('Virtual Machines'),
         badge=lambda obj: obj.virtual_machines.count(),
@@ -194,6 +196,13 @@ class ClusterDevicesView(generic.ObjectChildrenView):
     table = DeviceTable
     filterset = DeviceFilterSet
     template_name = 'virtualization/cluster/devices.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_remove_devices')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_remove_devices': {'change'},
+    })
     tab = ViewTab(
         label=_('Devices'),
         badge=lambda obj: obj.devices.count(),
@@ -353,6 +362,14 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
         permission='virtualization.view_vminterface',
         weight=500
     )
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
     def get_children(self, request, parent):
         return parent.interfaces.restrict(request.user, 'view').prefetch_related(