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

Merge pull request #8257 from netbox-community/develop

Release v3.1.5
Jeremy Stretch 4 лет назад
Родитель
Сommit
d3e2241ff7
59 измененных файлов с 468 добавлено и 309 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 18 0
      docs/release-notes/version-3.1.md
  4. 17 10
      netbox/dcim/forms/filtersets.py
  5. 2 1
      netbox/dcim/views.py
  6. 14 4
      netbox/extras/forms/models.py
  7. 10 4
      netbox/extras/scripts.py
  8. 1 0
      netbox/ipam/constants.py
  9. 2 3
      netbox/ipam/forms/models.py
  10. 77 0
      netbox/ipam/tests/test_views.py
  11. 1 4
      netbox/ipam/views.py
  12. 1 1
      netbox/netbox/settings.py
  13. 16 4
      netbox/netbox/views/generic.py
  14. 0 0
      netbox/project-static/dist/netbox-dark.css
  15. 0 0
      netbox/project-static/dist/netbox-light.css
  16. 0 0
      netbox/project-static/dist/netbox-print.css
  17. 8 10
      netbox/project-static/styles/netbox.scss
  18. 0 89
      netbox/project-static/styles/theme-base.scss
  19. 14 7
      netbox/project-static/styles/theme-dark.scss
  20. 37 18
      netbox/project-static/styles/theme-light.scss
  21. 4 1
      netbox/templates/base/layout.html
  22. 4 1
      netbox/templates/dcim/device/consoleports.html
  23. 4 1
      netbox/templates/dcim/device/consoleserverports.html
  24. 4 1
      netbox/templates/dcim/device/devicebays.html
  25. 4 1
      netbox/templates/dcim/device/frontports.html
  26. 4 1
      netbox/templates/dcim/device/interfaces.html
  27. 4 1
      netbox/templates/dcim/device/inventory.html
  28. 4 1
      netbox/templates/dcim/device/poweroutlets.html
  29. 4 1
      netbox/templates/dcim/device/powerports.html
  30. 4 1
      netbox/templates/dcim/device/rearports.html
  31. 1 1
      netbox/templates/dcim/devicebay_populate.html
  32. 2 0
      netbox/templates/dcim/rack_elevation_list.html
  33. 35 26
      netbox/templates/extras/report.html
  34. 1 1
      netbox/templates/extras/report_result.html
  35. 47 49
      netbox/templates/extras/script.html
  36. 5 1
      netbox/templates/generic/object.html
  37. 13 6
      netbox/templates/generic/object_delete.html
  38. 4 2
      netbox/templates/generic/object_list.html
  39. 20 0
      netbox/templates/htmx/delete_form.html
  40. 7 0
      netbox/templates/inc/htmx_modal.html
  41. 4 1
      netbox/templates/ipam/aggregate/prefixes.html
  42. 4 1
      netbox/templates/ipam/iprange/ip_addresses.html
  43. 4 1
      netbox/templates/ipam/prefix/ip_addresses.html
  44. 4 1
      netbox/templates/ipam/prefix/ip_ranges.html
  45. 4 1
      netbox/templates/ipam/prefix/prefixes.html
  46. 0 5
      netbox/templates/ipam/prefix_delete.html
  47. 0 6
      netbox/templates/ipam/vlan/base.html
  48. 4 3
      netbox/templates/ipam/vlan/interfaces.html
  49. 4 3
      netbox/templates/ipam/vlan/vminterfaces.html
  50. 4 3
      netbox/templates/virtualization/cluster/devices.html
  51. 4 3
      netbox/templates/virtualization/cluster/virtual_machines.html
  52. 4 1
      netbox/templates/virtualization/virtualmachine/interfaces.html
  53. 2 2
      netbox/utilities/forms/fields.py
  54. 0 10
      netbox/utilities/forms/widgets.py
  55. 8 2
      netbox/utilities/templates/buttons/delete.html
  56. 0 1
      netbox/utilities/templates/widgets/select_contenttype.html
  57. 6 0
      netbox/virtualization/tables.py
  58. 15 10
      netbox/wireless/forms/bulk_edit.py
  59. 2 2
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.1.4
+      placeholder: v3.1.5
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.1.4
+      placeholder: v3.1.5
     validations:
       required: true
   - type: dropdown

+ 18 - 0
docs/release-notes/version-3.1.md

@@ -1,5 +1,23 @@
 # NetBox v3.1
 
+## v3.1.5 (2022-01-06)
+
+### Enhancements
+
+* [#8231](https://github.com/netbox-community/netbox/issues/8231) - Use in-page dialogs for confirming object deletion
+* [#8244](https://github.com/netbox-community/netbox/issues/8244) - Add length & length unit fields to cable filter form
+* [#8252](https://github.com/netbox-community/netbox/issues/8252) - Linkify type and group columns in clusters table
+
+### Bug Fixes
+
+* [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view
+* [#8224](https://github.com/netbox-community/netbox/issues/8224) - Fix KeyError exception when creating FHRP group with IP address and protocol "other"
+* [#8226](https://github.com/netbox-community/netbox/issues/8226) - Honor return URL after populating a device bay
+* [#8228](https://github.com/netbox-community/netbox/issues/8228) - Optional ChoiceVar fields should not force a selection
+* [#8255](https://github.com/netbox-community/netbox/issues/8255) - Fix bulk editing of authentication parameters for wireless LANs and links
+
+---
+
 ## v3.1.4 (2022-01-03)
 
 ### Enhancements

+ 17 - 10
netbox/dcim/forms/filtersets.py

@@ -578,7 +578,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['site_id', 'rack_id', 'device_id'],
-        ['type', 'status', 'color'],
+        ['type', 'status', 'color', 'length', 'length_unit'],
         ['tenant_group_id', 'tenant_id'],
     ]
     region_id = DynamicModelMultipleChoiceField(
@@ -603,6 +603,16 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
             'site_id': '$site_id'
         }
     )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site_id',
+            'tenant_id': '$tenant_id',
+            'rack_id': '$rack_id',
+        },
+        label=_('Device')
+    )
     type = forms.MultipleChoiceField(
         choices=add_blank_choice(CableTypeChoices),
         required=False,
@@ -616,15 +626,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     color = ColorField(
         required=False
     )
-    device_id = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site_id',
-            'tenant_id': '$tenant_id',
-            'rack_id': '$rack_id',
-        },
-        label=_('Device')
+    length = forms.IntegerField(
+        required=False
+    )
+    length_unit = forms.ChoiceField(
+        choices=add_blank_choice(CableLengthUnitChoices),
+        required=False
     )
     tag = TagFilterField(model)
 

+ 2 - 1
netbox/dcim/views.py

@@ -2035,8 +2035,9 @@ class DeviceBayPopulateView(generic.ObjectEditView):
             device_bay.installed_device = form.cleaned_data['installed_device']
             device_bay.save()
             messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
+            return_url = self.get_return_url(request)
 
-            return redirect('dcim:device', pk=device_bay.device.pk)
+            return redirect(return_url)
 
         return render(request, 'dcim/devicebay_populate.html', {
             'device_bay': device_bay,

+ 14 - 4
netbox/extras/forms/models.py

@@ -7,8 +7,8 @@ from extras.models import *
 from extras.utils import FeatureQuery
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
-    add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField,
-    ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
+    add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
+    DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
 )
 from virtualization.models import Cluster, ClusterGroup
 
@@ -41,6 +41,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
             ('Values', ('default', 'choices')),
             ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
         )
+        widgets = {
+            'type': StaticSelect(),
+            'filter_logic': StaticSelect(),
+        }
 
 
 class CustomLinkForm(BootstrapMixin, forms.ModelForm):
@@ -57,6 +61,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
             ('Templates', ('link_text', 'link_url')),
         )
         widgets = {
+            'button_class': StaticSelect(),
             'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
             'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
         }
@@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
         model = Webhook
         fields = '__all__'
         fieldsets = (
-            ('Webhook', ('name', 'enabled')),
-            ('Assigned Models', ('content_types',)),
+            ('Webhook', ('name', 'content_types', 'enabled')),
             ('Events', ('type_create', 'type_update', 'type_delete')),
             ('HTTP Request', (
                 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
@@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
             ('Conditions', ('conditions',)),
             ('SSL', ('ssl_verification', 'ca_file_path')),
         )
+        labels = {
+            'type_create': 'Creations',
+            'type_update': 'Updates',
+            'type_delete': 'Deletions',
+        }
         widgets = {
+            'http_method': StaticSelect(),
             'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
             'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
         }

+ 10 - 4
netbox/extras/scripts.py

@@ -21,7 +21,7 @@ from extras.models import JobResult
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 from utilities.exceptions import AbortTransaction
-from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from .context_managers import change_logging
 from .forms import ScriptForm
 
@@ -164,16 +164,22 @@ class ChoiceVar(ScriptVariable):
     def __init__(self, choices, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        # Set field choices
-        self.field_attrs['choices'] = choices
+        # Set field choices, adding a blank choice to avoid forced selections
+        self.field_attrs['choices'] = add_blank_choice(choices)
 
 
-class MultiChoiceVar(ChoiceVar):
+class MultiChoiceVar(ScriptVariable):
     """
     Like ChoiceVar, but allows for the selection of multiple choices.
     """
     form_field = forms.MultipleChoiceField
 
+    def __init__(self, choices, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Set field choices
+        self.field_attrs['choices'] = choices
+
 
 class ObjectVar(ScriptVariable):
     """

+ 1 - 0
netbox/ipam/constants.py

@@ -65,6 +65,7 @@ FHRP_PROTOCOL_ROLE_MAPPINGS = {
     FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP,
     FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP,
     FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP,
+    FHRPGroupProtocolChoices.PROTOCOL_OTHER: IPAddressRoleChoices.ROLE_VIP,
 }
 
 

+ 2 - 3
netbox/ipam/forms/models.py

@@ -580,7 +580,7 @@ class FHRPGroupForm(CustomFieldModelForm):
                 vrf=self.cleaned_data['ip_vrf'],
                 address=self.cleaned_data['ip_address'],
                 status=self.cleaned_data['ip_status'],
-                role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']],
+                role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
                 assigned_object=instance
             )
             ipaddress.save()
@@ -628,8 +628,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
 class VLANGroupForm(CustomFieldModelForm):
     scope_type = ContentTypeChoiceField(
         queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
-        required=False,
-        widget=StaticSelect
+        required=False
     )
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),

+ 77 - 0
netbox/ipam/tests/test_views.py

@@ -1,5 +1,7 @@
 import datetime
 
+from django.test import override_settings
+from django.urls import reverse
 from netaddr import IPNetwork
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
@@ -222,6 +224,21 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
         }
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_aggregate_prefixes(self):
+        rir = RIR.objects.first()
+        aggregate = Aggregate.objects.create(prefix=IPNetwork('192.168.0.0/16'), rir=rir)
+        prefixes = (
+            Prefix(prefix=IPNetwork('192.168.1.0/24')),
+            Prefix(prefix=IPNetwork('192.168.2.0/24')),
+            Prefix(prefix=IPNetwork('192.168.3.0/24')),
+        )
+        Prefix.objects.bulk_create(prefixes)
+        self.assertEqual(aggregate.get_child_prefixes().count(), 3)
+
+        url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
 
 class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = Role
@@ -319,6 +336,48 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
         }
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_prefix_prefixes(self):
+        prefixes = (
+            Prefix(prefix=IPNetwork('192.168.0.0/16')),
+            Prefix(prefix=IPNetwork('192.168.1.0/24')),
+            Prefix(prefix=IPNetwork('192.168.2.0/24')),
+            Prefix(prefix=IPNetwork('192.168.3.0/24')),
+        )
+        Prefix.objects.bulk_create(prefixes)
+        self.assertEqual(prefixes[0].get_child_prefixes().count(), 3)
+
+        url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_prefix_ipranges(self):
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
+        ip_ranges = (
+            IPRange(start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99),
+            IPRange(start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99),
+            IPRange(start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99),
+        )
+        IPRange.objects.bulk_create(ip_ranges)
+        self.assertEqual(prefix.get_child_ranges().count(), 3)
+
+        url = reverse('ipam:prefix_ipranges', kwargs={'pk': prefix.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_prefix_ipaddresses(self):
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
+        ip_addresses = (
+            IPAddress(address=IPNetwork('192.168.0.1/16')),
+            IPAddress(address=IPNetwork('192.168.0.2/16')),
+            IPAddress(address=IPNetwork('192.168.0.3/16')),
+        )
+        IPAddress.objects.bulk_create(ip_addresses)
+        self.assertEqual(prefix.get_child_ips().count(), 3)
+
+        url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
 
 class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = IPRange
@@ -377,6 +436,24 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
         }
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_iprange_ipaddresses(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.168.0.1/24'),
+            end_address=IPNetwork('192.168.0.100/24'),
+            size=99
+        )
+        ip_addresses = (
+            IPAddress(address=IPNetwork('192.168.0.1/24')),
+            IPAddress(address=IPNetwork('192.168.0.2/24')),
+            IPAddress(address=IPNetwork('192.168.0.3/24')),
+        )
+        IPAddress.objects.bulk_create(ip_addresses)
+        self.assertEqual(iprange.get_child_ips().count(), 3)
+
+        url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
 
 class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = IPAddress

+ 1 - 4
netbox/ipam/views.py

@@ -505,9 +505,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
     template_name = 'ipam/prefix/ip_addresses.html'
 
     def get_children(self, request, parent):
-        return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
-            'vrf', 'role', 'tenant',
-        )
+        return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant')
 
     def prep_table_data(self, request, queryset, parent):
         show_available = bool(request.GET.get('show_available', 'true') == 'true')
@@ -531,7 +529,6 @@ class PrefixEditView(generic.ObjectEditView):
 
 class PrefixDeleteView(generic.ObjectDeleteView):
     queryset = Prefix.objects.all()
-    template_name = 'ipam/prefix_delete.html'
 
 
 class PrefixBulkImportView(generic.BulkImportView):

+ 1 - 1
netbox/netbox/settings.py

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
 # Environment setup
 #
 
-VERSION = '3.1.4'
+VERSION = '3.1.5'
 
 # Hostname
 HOSTNAME = platform.node()

+ 16 - 4
netbox/netbox/views/generic.py

@@ -10,6 +10,7 @@ from django.db.models import ManyToManyField, ProtectedError
 from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
 from django.utils.html import escape
 from django.utils.http import is_safe_url
 from django.utils.safestring import mark_safe
@@ -430,10 +431,21 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         obj = self.get_object(kwargs)
         form = ConfirmationForm(initial=request.GET)
 
+        # If this is an HTMX request, return only the rendered deletion form as modal content
+        if is_htmx(request):
+            viewname = f'{self.queryset.model._meta.app_label}:{self.queryset.model._meta.model_name}_delete'
+            form_url = reverse(viewname, kwargs={'pk': obj.pk})
+            return render(request, 'htmx/delete_form.html', {
+                'object': obj,
+                'object_type': self.queryset.model._meta.verbose_name,
+                'form': form,
+                'form_url': form_url,
+            })
+
         return render(request, self.template_name, {
-            'obj': obj,
+            'object': obj,
+            'object_type': self.queryset.model._meta.verbose_name,
             'form': form,
-            'obj_type': self.queryset.model._meta.verbose_name,
             'return_url': self.get_return_url(request, obj),
         })
 
@@ -466,9 +478,9 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             logger.debug("Form validation failed")
 
         return render(request, self.template_name, {
-            'obj': obj,
+            'object': obj,
+            'object_type': self.queryset.model._meta.verbose_name,
             'form': form,
-            'obj_type': self.queryset.model._meta.verbose_name,
             'return_url': self.get_return_url(request, obj),
         })
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-print.css


+ 8 - 10
netbox/project-static/styles/netbox.scss

@@ -358,7 +358,7 @@ nav.search {
   // Don't overtake dropdowns
   z-index: 999;
   justify-content: center;
-  background-color: var(--nbx-body-bg);
+  background-color: $navbar-light-color;
 
   .search-container {
     display: flex;
@@ -452,8 +452,8 @@ main.login-container {
 }
 
 .footer {
+  background-color: $tab-content-bg;
   padding: 0;
-
   .nav-link {
     padding: 0.5rem;
   }
@@ -517,6 +517,10 @@ h6.accordion-item-title {
   }
 }
 
+.navbar {
+  border-bottom: 1px solid $border-color;
+}
+
 .navbar-brand {
   padding-top: 0.75rem;
   padding-bottom: 0.75rem;
@@ -554,6 +558,7 @@ div.content-container {
   }
 
   div.content {
+    background-color: $tab-content-bg;
     flex: 1;
   }
 
@@ -898,6 +903,7 @@ div.card-overlay {
 
 // Tabbed content
 .nav-tabs {
+  background-color: $body-bg;
   .nav-link {
     &:hover {
       // Don't show a bottom-border on a hovered nav link because it overlaps with the .nav-tab border.
@@ -919,14 +925,6 @@ div.card-overlay {
   display: flex;
   flex-direction: column;
   padding: $spacer;
-  background-color: $tab-content-bg;
-  border-bottom: 1px solid $nav-tabs-border-color;
-
-  // Remove background and border when printing.
-  @media print {
-    background-color: var(--nbx-body-bg) !important;
-    border-bottom: none !important;
-  }
 }
 
 // Override masonry-layout styles when printing.

+ 0 - 89
netbox/project-static/styles/theme-base.scss

@@ -33,95 +33,6 @@ $darkest: #171b1d;
 
 @import '../node_modules/bootstrap/scss/variables';
 
-// Make color palette colors available as theme colors.
-// For example, you could use `.bg-red-100`, if needed.
-$theme-color-addons: (
-  'darker': $darker,
-  'darkest': $darkest,
-  'gray': $gray-400,
-  'gray-100': $gray-100,
-  'gray-200': $gray-200,
-  'gray-300': $gray-300,
-  'gray-400': $gray-400,
-  'gray-500': $gray-500,
-  'gray-600': $gray-600,
-  'gray-700': $gray-700,
-  'gray-800': $gray-800,
-  'gray-900': $gray-900,
-  'red-100': $red-100,
-  'red-200': $red-200,
-  'red-300': $red-300,
-  'red-400': $red-400,
-  'red-500': $red-500,
-  'red-600': $red-600,
-  'red-700': $red-700,
-  'red-800': $red-800,
-  'red-900': $red-900,
-  'yellow-100': $yellow-100,
-  'yellow-200': $yellow-200,
-  'yellow-300': $yellow-300,
-  'yellow-400': $yellow-400,
-  'yellow-500': $yellow-500,
-  'yellow-600': $yellow-600,
-  'yellow-700': $yellow-700,
-  'yellow-800': $yellow-800,
-  'yellow-900': $yellow-900,
-  'green-100': $green-100,
-  'green-200': $green-200,
-  'green-300': $green-300,
-  'green-400': $green-400,
-  'green-500': $green-500,
-  'green-600': $green-600,
-  'green-700': $green-700,
-  'green-800': $green-800,
-  'green-900': $green-900,
-  'blue-100': $blue-100,
-  'blue-200': $blue-200,
-  'blue-300': $blue-300,
-  'blue-400': $blue-400,
-  'blue-500': $blue-500,
-  'blue-600': $blue-600,
-  'blue-700': $blue-700,
-  'blue-800': $blue-800,
-  'blue-900': $blue-900,
-  'cyan-100': $cyan-100,
-  'cyan-200': $cyan-200,
-  'cyan-300': $cyan-300,
-  'cyan-400': $cyan-400,
-  'cyan-500': $cyan-500,
-  'cyan-600': $cyan-600,
-  'cyan-700': $cyan-700,
-  'cyan-800': $cyan-800,
-  'cyan-900': $cyan-900,
-  'indigo-100': $indigo-100,
-  'indigo-200': $indigo-200,
-  'indigo-300': $indigo-300,
-  'indigo-400': $indigo-400,
-  'indigo-500': $indigo-500,
-  'indigo-600': $indigo-600,
-  'indigo-700': $indigo-700,
-  'indigo-800': $indigo-800,
-  'indigo-900': $indigo-900,
-  'purple-100': $purple-100,
-  'purple-200': $purple-200,
-  'purple-300': $purple-300,
-  'purple-400': $purple-400,
-  'purple-500': $purple-500,
-  'purple-600': $purple-600,
-  'purple-700': $purple-700,
-  'purple-800': $purple-800,
-  'purple-900': $purple-900,
-  'pink-100': $pink-100,
-  'pink-200': $pink-200,
-  'pink-300': $pink-300,
-  'pink-400': $pink-400,
-  'pink-500': $pink-500,
-  'pink-600': $pink-600,
-  'pink-700': $pink-700,
-  'pink-800': $pink-800,
-  'pink-900': $pink-900,
-);
-
 // This is the same value as the default from Bootstrap, but it needs to be in scope prior to
 // importing _variables.scss from Bootstrap.
 $btn-close-width: 1em;

+ 14 - 7
netbox/project-static/styles/theme-dark.scss

@@ -3,6 +3,7 @@
 @use 'sass:map';
 @import './theme-base';
 
+// Theme colors (BS5 classes)
 $primary: $blue-300;
 $secondary: $gray-500;
 $success: $green-300;
@@ -13,6 +14,7 @@ $light: $gray-300;
 $dark: $gray-500;
 
 $theme-colors: (
+  // BS5 theme colors
   'primary': $primary,
   'secondary': $secondary,
   'success': $success,
@@ -21,18 +23,23 @@ $theme-colors: (
   'danger': $danger,
   'light': $light,
   'dark': $dark,
-  'red': $red-300,
-  'yellow': $yellow-300,
-  'green': $green-300,
+
+  // General-purpose palette
   'blue': $blue-300,
-  'cyan': $cyan-300,
   'indigo': $indigo-300,
   'purple': $purple-300,
   'pink': $pink-300,
+  'red': $red-300,
+  'orange': $orange-300,
+  'yellow': $yellow-300,
+  'green': $green-300,
+  'teal': $teal-300,
+  'cyan': $cyan-300,
+  'gray': $gray-300,
+  'black': $black,
+  'white': $white,
 );
 
-$theme-colors: map-merge($theme-colors, $theme-color-addons);
-
 // Gradient
 $gradient: linear-gradient(180deg, rgba($white, 0.15), rgba($white, 0));
 
@@ -139,7 +146,7 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
 $nav-pills-link-active-color: $component-active-color;
 $nav-pills-link-active-bg: $component-active-bg;
 
-$navbar-light-color: $gray-500;
+$navbar-light-color: $darkest;
 $navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
 $navbar-light-toggler-border-color: $gray-700;
 

+ 37 - 18
netbox/project-static/styles/theme-light.scss

@@ -2,28 +2,47 @@
 
 @import './theme-base.scss';
 
-$input-border-color: $gray-200;
-
-$theme-colors: map-merge(
-  $theme-colors,
-  (
-    'primary': #337ab7,
-    'info': #54d6f0,
-    'red': $red-500,
-    'yellow': $yellow-500,
-    'green': $green-500,
-    'blue': $blue-500,
-    'cyan': $cyan-500,
-    'indigo': $indigo-500,
-    'purple': $purple-500,
-    'pink': $pink-500,
-  )
-);
+// Theme colors (BS5 classes)
+$primary: #337ab7;
+$secondary: $gray-600;
+$success: $green-500;
+$info: #54d6f0;
+$warning: $yellow-500;
+$danger: $red-500;
+$light: $gray-200;
+$dark: $gray-800;
 
-$theme-colors: map-merge($theme-colors, $theme-color-addons);
+$theme-colors: (
+  // BS5 theme colors
+  'primary': $primary,
+  'secondary': $secondary,
+  'success': $success,
+  'info': $info,
+  'warning': $warning,
+  'danger': $danger,
+  'light': $light,
+  'dark': $dark,
+
+  // General-purpose palette
+  'blue': $blue-500,
+  'indigo': $indigo-500,
+  'purple': $purple-500,
+  'pink': $pink-500,
+  'red': $red-500,
+  'orange': $orange-500,
+  'yellow': $yellow-500,
+  'green': $green-500,
+  'teal': $teal-500,
+  'cyan': $cyan-500,
+  'gray': $gray-500,
+  'black': $black,
+  'white': $white,
+);
 
 $light: $gray-200;
 
+$navbar-light-color: $gray-100;
+
 $card-cap-color: $gray-800;
 
 $accordion-bg: transparent;

+ 4 - 1
netbox/templates/base/layout.html

@@ -20,7 +20,7 @@
         </div>
 
         {# Top bar #}
-        <nav class="navbar navbar-light sticky-top flex-md-nowrap p-1 mb-3 search container-fluid border-bottom noprint">
+        <nav class="navbar navbar-light sticky-top flex-md-nowrap p-1 mb-3 search container-fluid noprint">
 
             {# Mobile Navigation #}
             <div class="nav-mobile">
@@ -103,6 +103,9 @@
           </div>
         {% endif %}
 
+        {# BS5 pop-up modals #}
+        {% block modals %}{% endblock %}
+
         {# Page footer #}
         <footer class="footer container-fluid">
           <div class="row align-items-center justify-content-between mx-0">

+ 4 - 1
netbox/templates/dcim/device/consoleports.html

@@ -42,5 +42,8 @@
         {% endif %}
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/dcim/device/consoleserverports.html

@@ -42,5 +42,8 @@
         {% endif %}
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/dcim/device/devicebays.html

@@ -39,5 +39,8 @@
         {% endif %}
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/dcim/device/frontports.html

@@ -42,5 +42,8 @@
         {% endif %}
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/dcim/device/interfaces.html

@@ -77,5 +77,8 @@
         {% endif %}
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/dcim/device/inventory.html

@@ -39,5 +39,8 @@
         {% endif %}
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/dcim/device/poweroutlets.html

@@ -42,5 +42,8 @@
         {% endif %}
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/dcim/device/powerports.html

@@ -42,5 +42,8 @@
         {% endif %}
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/dcim/device/rearports.html

@@ -42,5 +42,8 @@
         {% endif %}
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 1 - 1
netbox/templates/dcim/devicebay_populate.html

@@ -4,7 +4,7 @@
 {% render_errors form %}
 
 {% block content %}
-<form action="." method="post">
+<form action="" method="post">
     {% csrf_token %}
     <div class="row mb-3">
         <div class="col col-md-6 offset-md-3">

+ 2 - 0
netbox/templates/dcim/rack_elevation_list.html

@@ -73,3 +73,5 @@
 
   </div>
 {% endblock content-wrapper %}
+
+{% block modals %}{% endblock %}

+ 35 - 26
netbox/templates/extras/report.html

@@ -10,7 +10,7 @@
 {% block breadcrumbs %}
   <li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}">Reports</a></li>
   <li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>
-{% endblock %}
+{% endblock breadcrumbs %}
 
 {% block subtitle %}
   {% if report.description %}
@@ -18,33 +18,42 @@
       <div class="text-muted">{{ report.description|render_markdown }}</div>
     </div>
   {% endif %}
-{% endblock %}
+{% endblock subtitle %}
 
 {% block controls %}{% endblock %}
-{% block tabs %}{% endblock %}
 
-{% block content-wrapper %}
-  {% if perms.extras.run_report %}
-    <div class="px-3 float-end noprint">
-        <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
-            {% csrf_token %}
-            <button type="submit" name="_run" class="btn btn-primary">
-                {% if report.result %}
-                    <i class="mdi mdi-replay"></i> Run Again
-                {% else %}
-                    <i class="mdi mdi-play"></i> Run Report
-                {% endif %}
-            </button>
-        </form>
-    </div>
-  {% endif %}
-  <div class="row px-3">
-      <div class="col col-md-12">
-          {% if report.result %}
-              Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
-                  <strong>{{ report.result.created|annotated_date }}</strong>
-              </a>
-          {% endif %}
+{% block tabs %}
+  <ul class="nav nav-tabs px-3">
+    <li class="nav-item" role="presentation">
+      <a href="#report" role="tab" data-bs-toggle="tab" class="nav-link active">Report</a>
+    </li>
+  </ul>
+{% endblock tabs %}
+
+{% block content %}
+  <div role="tabpanel" class="tab-pane active" id="report">
+    {% if perms.extras.run_report %}
+      <div class="float-end noprint">
+          <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
+              {% csrf_token %}
+              <button type="submit" name="_run" class="btn btn-primary">
+                  {% if report.result %}
+                      <i class="mdi mdi-replay"></i> Run Again
+                  {% else %}
+                      <i class="mdi mdi-play"></i> Run Report
+                  {% endif %}
+              </button>
+          </form>
       </div>
+    {% endif %}
+    <div class="row">
+        <div class="col col-md-12">
+            {% if report.result %}
+                Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
+                    <strong>{{ report.result.created|annotated_date }}</strong>
+                </a>
+            {% endif %}
+        </div>
+    </div>
   </div>
-{% endblock %}
+{% endblock content %}

+ 1 - 1
netbox/templates/extras/report_result.html

@@ -1,7 +1,7 @@
 {% extends 'extras/report.html' %}
 
 {% block content-wrapper %}
-  <div class="row px-3">
+  <div class="row p-3">
     <div class="col col-md-12"{% if not result.completed %} hx-get="{% url 'extras:report_result' job_result_pk=result.pk %}" hx-trigger="every 3s"{% endif %}>
       {% include 'extras/htmx/report_result.html' %}
     </div>

+ 47 - 49
netbox/templates/extras/script.html

@@ -7,69 +7,67 @@
 
 {% block object_identifier %}
   {{ script.full_name }}
-{% endblock %}
+{% endblock object_identifier %}
 
 {% block breadcrumbs %}
   <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
   <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>
-{% endblock %}
+{% endblock breadcrumbs %}
 
 {% block subtitle %}
   <div class="object-subtitle">
     <div class="text-muted">{{ script.Meta.description|render_markdown }}</div>
   </div>
-{% endblock %}
+{% endblock subtitle %}
 
 {% block controls %}{% endblock %}
 
 {% block tabs %}
-<ul class="nav nav-tabs px-3">
-  <li class="nav-item" role="presentation">
-    <a href="#run" role="tab" data-bs-toggle="tab" class="nav-link active">Run</a>
-  </li>
-  <li class="nav-item" role="presentation">
-    <a href="#source" role="tab" data-bs-toggle="tab" class="nav-link">Source</a>
-  </li>
-</ul>
-{% endblock %}
+  <ul class="nav nav-tabs px-3">
+    <li class="nav-item" role="presentation">
+      <a href="#run" role="tab" data-bs-toggle="tab" class="nav-link active">Run</a>
+    </li>
+    <li class="nav-item" role="presentation">
+      <a href="#source" role="tab" data-bs-toggle="tab" class="nav-link">Source</a>
+    </li>
+  </ul>
+{% endblock tabs %}
 
-{% block content-wrapper %}
-  <div class="tab-content">
-    <div role="tabpanel" class="tab-pane active" id="run">
-      <div class="row">
-        <div class="col">
-          {% if not perms.extras.run_script %}
-            <div class="alert alert-warning">
-              <i class="mdi mdi-alert"></i>
-              You do not have permission to run scripts.
-            </div>
-          {% endif %}
-          <form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
-            {% csrf_token %}
-            <div class="field-group my-4">
-              {% if form.requires_input %}
-                <div class="row mb-2">
-                  <h5 class="offset-sm-3">Script Data</h5>
-                </div>
-              {% else %}
-                <div class="alert alert-info">
-                  <i class="mdi mdi-information"></i>
-                  This script does not require any input to run.
-                </div>
-              {% endif %}
-              {% render_form form %}
-            </div>
-            <div class="float-end">
-              <a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
-              <button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="mdi mdi-play"></i> Run Script</button>
-            </div>
-          </form>
-        </div>
+{% block content %}
+  <div role="tabpanel" class="tab-pane active" id="run">
+    <div class="row">
+      <div class="col">
+        {% if not perms.extras.run_script %}
+          <div class="alert alert-warning">
+            <i class="mdi mdi-alert"></i>
+            You do not have permission to run scripts.
+          </div>
+        {% endif %}
+        <form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
+          {% csrf_token %}
+          <div class="field-group my-4">
+            {% if form.requires_input %}
+              <div class="row mb-2">
+                <h5 class="offset-sm-3">Script Data</h5>
+              </div>
+            {% else %}
+              <div class="alert alert-info">
+                <i class="mdi mdi-information"></i>
+                This script does not require any input to run.
+              </div>
+            {% endif %}
+            {% render_form form %}
+          </div>
+          <div class="float-end">
+            <a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
+            <button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="mdi mdi-play"></i> Run Script</button>
+          </div>
+        </form>
       </div>
     </div>
-    <div role="tabpanel" class="tab-pane" id="source">
-      <code class="h6 my-3 d-block">{{ script.filename }}</code>
-      <pre class="block">{{ script.source }}</pre>
-    </div>
   </div>
-{% endblock content-wrapper %}
+  <div role="tabpanel" class="tab-pane" id="source">
+    <code class="h6 my-3 d-block">{{ script.filename }}</code>
+    <pre class="block">{{ script.source }}</pre>
+  </div>
+{% endblock content %}

+ 5 - 1
netbox/templates/generic/object.html

@@ -100,4 +100,8 @@
   <div class="tab-content">
     {% block content %}{% endblock %}
   </div>
-{% endblock %}
+{% endblock content-wrapper %}
+
+{% block modals %}
+  {% include 'inc/htmx_modal.html' %}
+{% endblock modals %}

+ 13 - 6
netbox/templates/generic/object_delete.html

@@ -1,9 +1,16 @@
-{% extends 'generic/confirmation_form.html' %}
+{% extends 'base/layout.html' %}
 {% load form_helpers %}
 
-{% block title %}Delete {{ obj_type }}?{% endblock %}
+{% block title %}Delete {{ object_type }}?{% endblock %}
 
-{% block message %}
-  <p>Are you sure you want to <strong class="text-danger">delete</strong> {{ obj_type }} <strong>{{ obj }}</strong>?</p>
-  {% block message_extra %}{% endblock %}
-{% endblock message %}
+{% block header %}{% endblock %}
+
+{% block content %}
+  <div class="modal" tabindex="-1" style="display: block; position: static">
+    <div class="modal-dialog">
+      <div class="modal-content" >
+        {% include 'htmx/delete_form.html' %}
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 4 - 2
netbox/templates/generic/object_list.html

@@ -133,6 +133,8 @@
     {% endif %}
   </div>
 
-  {# Table config form #}
-  {% table_config_form table table_name="ObjectTable" %}
 {% endblock content-wrapper %}
+
+{% block modals %}
+  {% table_config_form table table_name="ObjectTable" %}
+{% endblock modals %}

+ 20 - 0
netbox/templates/htmx/delete_form.html

@@ -0,0 +1,20 @@
+{% load form_helpers %}
+
+<form action="{{ form_url }}" method="post">
+  {% csrf_token %}
+  <div class="modal-header">
+    <h5 class="modal-title">Confirm Deletion</h5>
+  </div>
+  <div class="modal-body">
+    <p>Are you sure you want to <strong class="text-danger">delete</strong> {{ object_type }} <strong>{{ object }}</strong>?</p>
+    {% render_form form %}
+  </div>
+  <div class="modal-footer">
+    {% if return_url %}
+      <a href="{{ return_url }}" class="btn btn-outline-secondary">Cancel</a>
+    {% else %}
+      <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
+    {% endif %}
+    <button type="submit" class="btn btn-danger">Delete</button>
+  </div>
+</form>

+ 7 - 0
netbox/templates/inc/htmx_modal.html

@@ -0,0 +1,7 @@
+<div class="modal fade" id="htmx-modal" tabindex="-1" aria-hidden="true">
+  <div class="modal-dialog modal-dialog-centered">
+    <div class="modal-content" id="htmx-modal-content">
+      {# Dynamic content goes here #}
+    </div>
+  </div>
+</div>

+ 4 - 1
netbox/templates/ipam/aggregate/prefixes.html

@@ -37,5 +37,8 @@
       </div>
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/ipam/iprange/ip_addresses.html

@@ -35,5 +35,8 @@
       </div>
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/ipam/prefix/ip_addresses.html

@@ -35,5 +35,8 @@
       </div>
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/ipam/prefix/ip_ranges.html

@@ -35,5 +35,8 @@
       </div>
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/ipam/prefix/prefixes.html

@@ -37,5 +37,8 @@
       </div>
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {% table_config_form table %}
+{% endblock modals %}

+ 0 - 5
netbox/templates/ipam/prefix_delete.html

@@ -1,5 +0,0 @@
-{% extends 'generic/object_delete.html' %}
-
-{% block message_extra %}
-    <p>Note: This will <strong>not</strong> delete any child prefixes or IP addresses.</p>
-{% endblock %}

+ 0 - 6
netbox/templates/ipam/vlan/base.html

@@ -37,9 +37,3 @@
     {% endif %}
   </ul>
 {% endblock %}
-
-{% block content-wrapper %}
-  <div class="tab-content">
-    {% block content %}{% endblock %}
-  </div>
-{% endblock %}

+ 4 - 3
netbox/templates/ipam/vlan/interfaces.html

@@ -5,13 +5,14 @@
   <form method="post">
     {% csrf_token %}
     {% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %}
-
     <div class="card">
       <div class="card-body" id="object_list">
         {% include 'htmx/table.html' %}
       </div>
     </div>
-
   </form>
+{% endblock content %}
+
+{% block modals %}
   {% table_config_form table %}
-{% endblock %}
+{% endblock modals %}

+ 4 - 3
netbox/templates/ipam/vlan/vminterfaces.html

@@ -5,13 +5,14 @@
   <form method="post">
     {% csrf_token %}
     {% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %}
-
     <div class="card">
       <div class="card-body" id="object_list">
         {% include 'htmx/table.html' %}
       </div>
     </div>
-
   </form>
+{% endblock content %}
+
+{% block modals %}
   {% table_config_form table %}
-{% endblock %}
+{% endblock modals %}

+ 4 - 3
netbox/templates/virtualization/cluster/devices.html

@@ -6,13 +6,11 @@
   <form action="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}" method="post">
     {% csrf_token %}
     {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %}
-
     <div class="card">
       <div class="card-body" id="object_list">
         {% include 'htmx/table.html' %}
       </div>
     </div>
-
     <div class="noprint bulk-buttons">
       <div class="bulk-button-group">
         {% if perms.virtualization.change_cluster %}
@@ -23,5 +21,8 @@
       </div>
     </div>
   </form>
+{% endblock content %}
+
+{% block modals %}
   {% table_config_form table %}
-{% endblock %}
+{% endblock modals %}

+ 4 - 3
netbox/templates/virtualization/cluster/virtual_machines.html

@@ -6,13 +6,11 @@
   <form method="post">
     {% csrf_token %}
     {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %}
-
     <div class="card">
       <div class="card-body" id="object_list">
         {% include 'htmx/table.html' %}
       </div>
     </div>
-
     <div class="noprint bulk-buttons">
       <div class="bulk-button-group">
         {% if perms.virtualization.change_virtualmachine %}
@@ -28,5 +26,8 @@
       </div>
     </div>
   </form>
+{% endblock content %}
+
+{% block modals %}
   {% table_config_form table %}
-{% endblock %}
+{% endblock modals %}

+ 4 - 1
netbox/templates/virtualization/virtualmachine/interfaces.html

@@ -37,5 +37,8 @@
         <div class="clearfix"></div>
      </div>
   </form>
+{% endblock content %}
+
+{% block modals %}
   {% table_config_form table %}
-{% endblock %}
+{% endblock modals %}

+ 2 - 2
netbox/utilities/forms/fields.py

@@ -168,11 +168,11 @@ class ContentTypeChoiceMixin:
 
 
 class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField):
-    pass
+    widget = widgets.StaticSelect
 
 
 class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField):
-    pass
+    widget = widgets.StaticSelectMultiple
 
 
 #

+ 0 - 10
netbox/utilities/forms/widgets.py

@@ -14,7 +14,6 @@ __all__ = (
     'BulkEditNullBooleanSelect',
     'ClearableFileInput',
     'ColorSelect',
-    'ContentTypeSelect',
     'DatePicker',
     'DateTimePicker',
     'NumericArrayField',
@@ -110,15 +109,6 @@ class SelectWithPK(StaticSelect):
     option_template_name = 'widgets/select_option_with_pk.html'
 
 
-class ContentTypeSelect(StaticSelect):
-    """
-    Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example:
-        <option value="37" api-value="console-server-port">console server port</option>
-    This attribute can be used to reference the relevant API endpoint for a particular ContentType.
-    """
-    option_template_name = 'widgets/select_contenttype.html'
-
-
 class SelectSpeedWidget(forms.NumberInput):
     """
     Speed field with dropdown selections for convenience.

+ 8 - 2
netbox/utilities/templates/buttons/delete.html

@@ -1,3 +1,9 @@
-<a href="{{ url }}" class="btn btn-sm btn-danger" role="button">
-    <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>&nbsp;Delete
+<a href="#"
+  hx-get="{{ url }}"
+  hx-target="#htmx-modal-content"
+  class="btn btn-sm btn-danger"
+  data-bs-toggle="modal"
+  data-bs-target="#htmx-modal"
+>
+  <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>&nbsp;Delete
 </a>

+ 0 - 1
netbox/utilities/templates/widgets/select_contenttype.html

@@ -1 +0,0 @@
-<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %}{% if widget.value %} api-value="{{ widget.label|slugify }}"{% endif %}>{{ widget.label.label|default:widget.label|capfirst }}</option>

+ 6 - 0
netbox/virtualization/tables.py

@@ -80,6 +80,12 @@ class ClusterTable(BaseTable):
     name = tables.Column(
         linkify=True
     )
+    type = tables.Column(
+        linkify=True
+    )
+    group = tables.Column(
+        linkify=True
+    )
     tenant = tables.Column(
         linkify=True
     )

+ 15 - 10
netbox/wireless/forms/bulk_edit.py

@@ -3,7 +3,7 @@ from django import forms
 from dcim.choices import LinkStatusChoices
 from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.models import VLAN
-from utilities.forms import DynamicModelChoiceField
+from utilities.forms import add_blank_choice, DynamicModelChoiceField
 from wireless.choices import *
 from wireless.constants import SSID_MAX_LENGTH
 from wireless.models import *
@@ -45,24 +45,27 @@ class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         required=False,
+        label='VLAN'
     )
     ssid = forms.CharField(
         max_length=SSID_MAX_LENGTH,
-        required=False
+        required=False,
+        label='SSID'
     )
     description = forms.CharField(
         required=False
     )
     auth_type = forms.ChoiceField(
-        choices=WirelessAuthTypeChoices,
+        choices=add_blank_choice(WirelessAuthTypeChoices),
         required=False
     )
     auth_cipher = forms.ChoiceField(
-        choices=WirelessAuthCipherChoices,
+        choices=add_blank_choice(WirelessAuthCipherChoices),
         required=False
     )
     auth_psk = forms.CharField(
-        required=False
+        required=False,
+        label='Pre-shared key'
     )
 
     class Meta:
@@ -76,25 +79,27 @@ class WirelessLinkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     )
     ssid = forms.CharField(
         max_length=SSID_MAX_LENGTH,
-        required=False
+        required=False,
+        label='SSID'
     )
     status = forms.ChoiceField(
-        choices=LinkStatusChoices,
+        choices=add_blank_choice(LinkStatusChoices),
         required=False
     )
     description = forms.CharField(
         required=False
     )
     auth_type = forms.ChoiceField(
-        choices=WirelessAuthTypeChoices,
+        choices=add_blank_choice(WirelessAuthTypeChoices),
         required=False
     )
     auth_cipher = forms.ChoiceField(
-        choices=WirelessAuthCipherChoices,
+        choices=add_blank_choice(WirelessAuthCipherChoices),
         required=False
     )
     auth_psk = forms.CharField(
-        required=False
+        required=False,
+        label='Pre-shared key'
     )
 
     class Meta:

+ 2 - 2
requirements.txt

@@ -1,4 +1,4 @@
-Django==3.2.10
+Django==3.2.11
 django-cors-headers==3.10.1
 django-debug-toolbar==3.2.4
 django-filter==21.1
@@ -18,7 +18,7 @@ gunicorn==20.1.0
 Jinja2==3.0.3
 Markdown==3.3.6
 markdown-include==0.6.0
-mkdocs-material==8.1.3
+mkdocs-material==8.1.4
 netaddr==0.8.0
 Pillow==8.4.0
 psycopg2-binary==2.9.3

Некоторые файлы не были показаны из-за большого количества измененных файлов