Răsfoiți Sursa

Merge branch 'develop' into feature

jeremystretch 4 ani în urmă
părinte
comite
c8713d94d8
73 a modificat fișierele cu 579 adăugiri și 242 ștergeri
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 6 0
      docs/development/getting-started.md
  4. 4 2
      docs/index.md
  5. 9 1
      docs/release-notes/index.md
  6. 37 1
      docs/release-notes/version-3.1.md
  7. 22 0
      netbox/circuits/tables.py
  8. 8 0
      netbox/dcim/choices.py
  9. 17 10
      netbox/dcim/forms/filtersets.py
  10. 2 1
      netbox/dcim/views.py
  11. 1 1
      netbox/extras/forms/customfields.py
  12. 15 5
      netbox/extras/forms/models.py
  13. 4 4
      netbox/extras/models/customfields.py
  14. 10 4
      netbox/extras/scripts.py
  15. 12 6
      netbox/extras/tests/test_customfields.py
  16. 1 0
      netbox/ipam/constants.py
  17. 4 3
      netbox/ipam/forms/models.py
  18. 77 0
      netbox/ipam/tests/test_views.py
  19. 1 4
      netbox/ipam/views.py
  20. 16 4
      netbox/netbox/views/generic/object_views.py
  21. 0 0
      netbox/project-static/dist/netbox-dark.css
  22. 0 0
      netbox/project-static/dist/netbox-light.css
  23. 0 0
      netbox/project-static/dist/netbox-print.css
  24. 12 10
      netbox/project-static/styles/netbox.scss
  25. 1 6
      netbox/project-static/styles/sidenav.scss
  26. 1 1
      netbox/project-static/styles/theme-dark.scss
  27. 2 2
      netbox/project-static/styles/theme-light.scss
  28. 1 1
      netbox/project-static/styles/variables.scss
  29. 4 1
      netbox/templates/base/layout.html
  30. 5 1
      netbox/templates/dcim/device/consoleports.html
  31. 5 1
      netbox/templates/dcim/device/consoleserverports.html
  32. 5 1
      netbox/templates/dcim/device/devicebays.html
  33. 5 1
      netbox/templates/dcim/device/frontports.html
  34. 5 1
      netbox/templates/dcim/device/interfaces.html
  35. 5 1
      netbox/templates/dcim/device/inventory.html
  36. 5 1
      netbox/templates/dcim/device/poweroutlets.html
  37. 5 1
      netbox/templates/dcim/device/powerports.html
  38. 5 1
      netbox/templates/dcim/device/rearports.html
  39. 1 1
      netbox/templates/dcim/devicebay_populate.html
  40. 2 0
      netbox/templates/dcim/rack_elevation_list.html
  41. 35 26
      netbox/templates/extras/report.html
  42. 1 1
      netbox/templates/extras/report_result.html
  43. 47 49
      netbox/templates/extras/script.html
  44. 5 1
      netbox/templates/generic/object.html
  45. 13 6
      netbox/templates/generic/object_delete.html
  46. 4 2
      netbox/templates/generic/object_list.html
  47. 20 0
      netbox/templates/htmx/delete_form.html
  48. 7 0
      netbox/templates/inc/htmx_modal.html
  49. 1 1
      netbox/templates/inc/profile_button.html
  50. 5 1
      netbox/templates/ipam/aggregate/prefixes.html
  51. 5 1
      netbox/templates/ipam/iprange/ip_addresses.html
  52. 5 1
      netbox/templates/ipam/prefix/ip_addresses.html
  53. 5 1
      netbox/templates/ipam/prefix/ip_ranges.html
  54. 5 1
      netbox/templates/ipam/prefix/prefixes.html
  55. 0 5
      netbox/templates/ipam/prefix_delete.html
  56. 0 6
      netbox/templates/ipam/vlan/base.html
  57. 5 3
      netbox/templates/ipam/vlan/interfaces.html
  58. 5 3
      netbox/templates/ipam/vlan/vminterfaces.html
  59. 4 0
      netbox/templates/tenancy/tenant.html
  60. 5 3
      netbox/templates/virtualization/cluster/devices.html
  61. 5 3
      netbox/templates/virtualization/cluster/virtual_machines.html
  62. 5 1
      netbox/templates/virtualization/virtualmachine/interfaces.html
  63. 4 3
      netbox/tenancy/api/views.py
  64. 2 1
      netbox/tenancy/views.py
  65. 23 6
      netbox/utilities/forms/fields.py
  66. 0 10
      netbox/utilities/forms/widgets.py
  67. 10 10
      netbox/utilities/tables/columns.py
  68. 8 2
      netbox/utilities/templates/buttons/delete.html
  69. 0 1
      netbox/utilities/templates/widgets/select_contenttype.html
  70. 5 3
      netbox/utilities/validators.py
  71. 6 0
      netbox/virtualization/tables.py
  72. 15 10
      netbox/wireless/forms/bulk_edit.py
  73. 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

+ 6 - 0
docs/development/getting-started.md

@@ -114,6 +114,12 @@ This ensures that your development environment is now complete and operational.
 !!! info "IDE Integration"
     Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment.
 
+## Populating Demo Data
+
+Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
+
+The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
+
 ## Running Tests
 
 Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command.

+ 4 - 2
docs/index.md

@@ -50,7 +50,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
 | Application        | Django/Python     |
 | Database           | PostgreSQL 10+    |
 | Task queuing       | Redis/django-rq   |
-| Live device access | NAPALM            |
+| Live device access | NAPALM (optional) |
 
 ## Supported Python Versions
 
@@ -58,4 +58,6 @@ NetBox supports Python 3.8, 3.9, and 3.10 environments.
 
 ## Getting Started
 
-See the [installation guide](installation/index.md) for help getting NetBox up and running quickly.
+Minor NetBox releases (e.g. v3.1) are published three times a year; in April, August, and December. These typically introduce major new features and may contain breaking API changes. Patch releases are published roughly every one to two weeks to resolve bugs and fulfill minor feature requests. These are backward-compatible with previous releases unless otherwise noted. The NetBox maintainers strongly recommend running the latest stable release whenever possible.
+
+Please see the [official installation guide](installation/index.md) for detailed instructions on obtaining and installing NetBox.

+ 9 - 1
docs/release-notes/index.md

@@ -1,6 +1,14 @@
 # Release Notes
 
-Listed below are the major features introduced in each NetBox release. For more detail on a specific release train, see its individual release notes page. 
+NetBox releases are numbered as major, minor, and patch releases. For example, version 3.1.0 is a minor release, and v3.1.5 is a patch release. Briefly, these can be described as follows:
+
+* **Major** - Introduces or removes an entire API or other core functionality
+* **Minor** - Implements major new features but may include breaking changes for API consumers or other integrations
+* **Patch** - A maintenance release which fixes bugs and may introduce backward-compatible enhancements
+
+Minor releases are published in April, August, and December of each calendar year. Patch releases are published as needed to address bugs and fulfill minor feature requests, typically around every one to two weeks.
+
+This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
 
 #### [Version 3.1](./version-3.1.md) (December 2021)
 

+ 37 - 1
docs/release-notes/version-3.1.md

@@ -1,6 +1,42 @@
 # NetBox v3.1
 
-## v3.1.5 (FUTURE)
+## v3.1.6 (FUTURE)
+
+### Enhancements
+
+* [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table
+* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats
+* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types
+* [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables
+
+### Bug Fixes
+
+* [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer
+* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form
+* [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views
+* [#8305](https://github.com/netbox-community/netbox/issues/8305) - Fix assignment of custom field data to FHRP groups via UI
+* [#8306](https://github.com/netbox-community/netbox/issues/8306) - Redirect user to previous page after login
+* [#8314](https://github.com/netbox-community/netbox/issues/8314) - Prevent custom fields with default values from appearing as applied filters erroneously
+* [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values
+* [#8319](https://github.com/netbox-community/netbox/issues/8319) - Custom URL fields should honor `ALLOWED_URL_SCHEMES` config parameter
+
+---
+
+## 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
 
 ---
 

+ 22 - 0
netbox/circuits/tables.py

@@ -22,11 +22,32 @@ CIRCUITTERMINATION_LINK = """
 {% endif %}
 """
 
+#
+# Table columns
+#
+
+
+class CommitRateColumn(tables.TemplateColumn):
+    """
+    Humanize the commit rate in the column view
+    """
+
+    template_code = """
+        {% load helpers %}
+        {{ record.commit_rate|humanize_speed }}
+        """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(template_code=self.template_code, *args, **kwargs)
+
+    def value(self, value):
+        return str(value) if value else None
 
 #
 # Providers
 #
 
+
 class ProviderTable(BaseTable):
     pk = ToggleColumn()
     name = tables.Column(
@@ -118,6 +139,7 @@ class CircuitTable(BaseTable):
         template_code=CIRCUITTERMINATION_LINK,
         verbose_name='Side Z'
     )
+    commit_rate = CommitRateColumn()
     comments = MarkdownColumn()
     tags = TagColumn(
         url_name='circuits:circuit_list'

+ 8 - 0
netbox/dcim/choices.py

@@ -793,6 +793,10 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
     TYPE_FLEXSTACK = 'cisco-flexstack'
     TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus'
+    TYPE_STACKWISE80 = 'cisco-stackwise-80'
+    TYPE_STACKWISE160 = 'cisco-stackwise-160'
+    TYPE_STACKWISE320 = 'cisco-stackwise-320'
+    TYPE_STACKWISE480 = 'cisco-stackwise-480'
     TYPE_JUNIPER_VCP = 'juniper-vcp'
     TYPE_SUMMITSTACK = 'extreme-summitstack'
     TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@@ -927,6 +931,10 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
                 (TYPE_FLEXSTACK, 'Cisco FlexStack'),
                 (TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'),
+                (TYPE_STACKWISE80, 'Cisco StackWise-80'),
+                (TYPE_STACKWISE160, 'Cisco StackWise-160'),
+                (TYPE_STACKWISE320, 'Cisco StackWise-320'),
+                (TYPE_STACKWISE480, 'Cisco StackWise-480'),
                 (TYPE_JUNIPER_VCP, 'Juniper VCP'),
                 (TYPE_SUMMITSTACK, 'Extreme SummitStack'),
                 (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),

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

@@ -678,7 +678,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(
@@ -703,6 +703,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,
@@ -716,15 +726,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

@@ -2379,8 +2379,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,

+ 1 - 1
netbox/extras/forms/customfields.py

@@ -124,5 +124,5 @@ class CustomFieldModelFilterForm(FilterForm):
         )
         for cf in custom_fields:
             field_name = f'cf_{cf.name}'
-            self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
+            self.fields[field_name] = cf.to_form_field(set_initial=False, enforce_required=False)
             self.custom_field_filters.append(field_name)

+ 15 - 5
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, ClusterType
 
@@ -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'}),
         }
@@ -77,7 +82,7 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
         model = ExportTemplate
         fields = '__all__'
         fieldsets = (
-            ('Custom Link', ('name', 'content_type', 'description')),
+            ('Export Template', ('name', 'content_type', 'description')),
             ('Template', ('template_code',)),
             ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
         )
@@ -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'}),
         }

+ 4 - 4
netbox/extras/models/customfields.py

@@ -16,8 +16,8 @@ from extras.utils import FeatureQuery, extras_features
 from netbox.models import ChangeLoggedModel
 from utilities import filters
 from utilities.forms import (
-    CSVChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, LaxURLField,
-    StaticSelectMultiple, StaticSelect, add_blank_choice,
+    CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
 )
 from utilities.querysets import RestrictedQuerySet
 from utilities.validators import validate_regex
@@ -283,7 +283,7 @@ class CustomField(ChangeLoggedModel):
         """
         Return a form field suitable for setting a CustomField's value for an object.
 
-        set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
+        set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
         enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
         for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
         """
@@ -332,7 +332,7 @@ class CustomField(ChangeLoggedModel):
                     choices=choices, required=required, initial=initial, widget=StaticSelect()
                 )
             else:
-                field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField
+                field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
                 field = field_class(
                     choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
                 )

+ 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):
     """

+ 12 - 6
netbox/extras/tests/test_customfields.py

@@ -810,6 +810,9 @@ class CustomFieldImportTest(TestCase):
             CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
                 'Choice A', 'Choice B', 'Choice C',
             ]),
+            CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
+                'Choice A', 'Choice B', 'Choice C',
+            ]),
         )
         for cf in custom_fields:
             cf.save()
@@ -820,19 +823,20 @@ class CustomFieldImportTest(TestCase):
         Import a Site in CSV format, including a value for each CustomField.
         """
         data = (
-            ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'),
-            ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'),
-            ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'),
-            ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''),
+            ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
+            ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
+            ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
+            ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
         )
         csv_data = '\n'.join(','.join(row) for row in data)
 
         response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
         self.assertEqual(response.status_code, 200)
+        self.assertEqual(Site.objects.count(), 3)
 
         # Validate data for site 1
         site1 = Site.objects.get(name='Site 1')
-        self.assertEqual(len(site1.custom_field_data), 8)
+        self.assertEqual(len(site1.custom_field_data), 9)
         self.assertEqual(site1.custom_field_data['text'], 'ABC')
         self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
         self.assertEqual(site1.custom_field_data['integer'], 123)
@@ -841,10 +845,11 @@ class CustomFieldImportTest(TestCase):
         self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
         self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
         self.assertEqual(site1.custom_field_data['select'], 'Choice A')
+        self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
 
         # Validate data for site 2
         site2 = Site.objects.get(name='Site 2')
-        self.assertEqual(len(site2.custom_field_data), 8)
+        self.assertEqual(len(site2.custom_field_data), 9)
         self.assertEqual(site2.custom_field_data['text'], 'DEF')
         self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
         self.assertEqual(site2.custom_field_data['integer'], 456)
@@ -853,6 +858,7 @@ class CustomFieldImportTest(TestCase):
         self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
         self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
         self.assertEqual(site2.custom_field_data['select'], 'Choice B')
+        self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
 
         # No custom field data should be set for site 3
         site3 = Site.objects.get(name='Site 3')

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

+ 4 - 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()
@@ -592,6 +592,8 @@ class FHRPGroupForm(CustomFieldModelForm):
         return instance
 
     def clean(self):
+        super().clean()
+
         ip_vrf = self.cleaned_data.get('ip_vrf')
         ip_address = self.cleaned_data.get('ip_address')
         ip_status = self.cleaned_data.get('ip_status')
@@ -628,8 +630,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):

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

@@ -9,6 +9,7 @@ from django.db.models import ProtectedError
 from django.forms.widgets import HiddenInput
 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
@@ -623,10 +624,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),
         })
 
@@ -664,9 +676,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),
         })
 

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox-print.css


+ 12 - 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;
   }
 
@@ -592,6 +597,10 @@ span.color-label {
   box-shadow: $box-shadow-sm;
 }
 
+.badge a {
+  color: inherit;
+}
+
 .btn {
   white-space: nowrap;
 }
@@ -898,6 +907,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 +929,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.

+ 1 - 6
netbox/project-static/styles/sidenav.scss

@@ -223,11 +223,6 @@
     font-weight: $font-weight-bold;
     color: var(--nbx-sidenav-parent-color);
 
-    &.active {
-      color: $accordion-button-active-color;
-      background: $accordion-button-active-bg;
-    }
-
     &:after {
       display: inline-block;
       margin-left: auto;
@@ -284,7 +279,7 @@
         font-size: $font-size-sm;
         color: var(--nbx-sidenav-link-color);
         white-space: nowrap;
-        transition: $transition-100ms-ease-in-out;
+        transition-duration: 0ms;
 
         &.active {
           background-color: var(--nbx-sidebar-link-active-bg);

+ 1 - 1
netbox/project-static/styles/theme-dark.scss

@@ -146,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;
 

+ 2 - 2
netbox/project-static/styles/theme-light.scss

@@ -2,8 +2,6 @@
 
 @import './theme-base.scss';
 
-$input-border-color: $gray-200;
-
 // Theme colors (BS5 classes)
 $primary: #337ab7;
 $secondary: $gray-600;
@@ -43,6 +41,8 @@ $theme-colors: (
 
 $light: $gray-200;
 
+$navbar-light-color: $gray-100;
+
 $card-cap-color: $gray-800;
 
 $accordion-bg: transparent;

+ 1 - 1
netbox/project-static/styles/variables.scss

@@ -5,7 +5,7 @@
   --nbx-sidebar-bg: #{$gray-200};
   --nbx-sidebar-scroll: #{$gray-500};
   --nbx-sidebar-link-hover-bg: #{rgba($gray-600, 0.15)};
-  --nbx-sidebar-link-active-bg: #{$blue-100};
+  --nbx-sidebar-link-active-bg: #9cc8f8;
   --nbx-sidebar-title-color: #{$text-muted};
   --nbx-sidebar-shadow: inset 0px -25px 20px -25px rgba(0, 0, 0, 0.25);
   --nbx-breadcrumb-bg: #{$light};

+ 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">

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -42,5 +42,9 @@
         {% endif %}
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {{ block.super }}
+  {% 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>

+ 1 - 1
netbox/templates/inc/profile_button.html

@@ -38,7 +38,7 @@
   </div>
 {% else %}
   <div class="btn-group">
-    <a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}">
+    <a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}?next={{ request.path }}">
       <i class="mdi mdi-login-variant"></i> Log In
     </a>
     <button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown">

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

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

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

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

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

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

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

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

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

@@ -37,5 +37,9 @@
       </div>
     </div>
   </form>
-  {% table_config_form table %}
 {% endblock %}
+
+{% block modals %}
+  {{ block.super }}
+  {% 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 %}

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

@@ -5,13 +5,15 @@
   <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 %}
+  {{ block.super }}
   {% table_config_form table %}
-{% endblock %}
+{% endblock modals %}

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

@@ -5,13 +5,15 @@
   <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 %}
+  {{ block.super }}
   {% table_config_form table %}
-{% endblock %}
+{% endblock modals %}

+ 4 - 0
netbox/templates/tenancy/tenant.html

@@ -95,6 +95,10 @@
                     <h2><a href="{% url 'virtualization:cluster_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cluster_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cluster_count }}</a></h2>
                     <p>Clusters</p>
                 </div>
+                <div class="col col-md-4 text-center">
+                    <h2><a href="{% url 'dcim:cable_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cable_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cable_count }}</a></h2>
+                    <p>Cables</p>
+                </div>
             </div>
         </div>
         {% plugin_right_page object %}

+ 5 - 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,9 @@
       </div>
     </div>
   </form>
+{% endblock content %}
+
+{% block modals %}
+  {{ block.super }}
   {% table_config_form table %}
-{% endblock %}
+{% endblock modals %}

+ 5 - 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,9 @@
       </div>
     </div>
   </form>
+{% endblock content %}
+
+{% block modals %}
+  {{ block.super }}
   {% table_config_form table %}
-{% endblock %}
+{% endblock modals %}

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

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

+ 4 - 3
netbox/tenancy/api/views.py

@@ -1,13 +1,13 @@
 from rest_framework.routers import APIRootView
 
 from circuits.models import Circuit
-from dcim.models import Device, Rack, Site
+from dcim.models import Device, Rack, Site, Cable
 from extras.api.views import CustomFieldModelViewSet
 from ipam.models import IPAddress, Prefix, VLAN, VRF
 from tenancy import filtersets
 from tenancy.models import *
 from utilities.utils import count_related
-from virtualization.models import VirtualMachine
+from virtualization.models import VirtualMachine, Cluster
 from . import serializers
 
 
@@ -47,7 +47,8 @@ class TenantViewSet(CustomFieldModelViewSet):
         site_count=count_related(Site, 'tenant'),
         virtualmachine_count=count_related(VirtualMachine, 'tenant'),
         vlan_count=count_related(VLAN, 'tenant'),
-        vrf_count=count_related(VRF, 'tenant')
+        vrf_count=count_related(VRF, 'tenant'),
+        cluster_count=count_related(Cluster, 'tenant')
     )
     serializer_class = serializers.TenantSerializer
     filterset_class = filtersets.TenantFilterSet

+ 2 - 1
netbox/tenancy/views.py

@@ -3,7 +3,7 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404
 
 from circuits.models import Circuit
-from dcim.models import Site, Rack, Device, RackReservation
+from dcim.models import Site, Rack, Device, RackReservation, Cable
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from netbox.views import generic
 from utilities.tables import configure_table
@@ -112,6 +112,7 @@ class TenantView(generic.ObjectView):
             'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
+            'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
         }
 
         return {

+ 23 - 6
netbox/utilities/forms/fields.py

@@ -31,6 +31,7 @@ __all__ = (
     'CSVDataField',
     'CSVFileField',
     'CSVModelChoiceField',
+    'CSVMultipleChoiceField',
     'CSVMultipleContentTypeField',
     'CSVTypedChoiceField',
     'DynamicModelChoiceField',
@@ -168,11 +169,11 @@ class ContentTypeChoiceMixin:
 
 
 class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField):
-    pass
+    widget = widgets.StaticSelect
 
 
 class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField):
-    pass
+    widget = widgets.StaticSelectMultiple
 
 
 #
@@ -263,10 +264,7 @@ class CSVFileField(forms.FileField):
         return value
 
 
-class CSVChoiceField(forms.ChoiceField):
-    """
-    Invert the provided set of choices to take the human-friendly label as input, and return the database value.
-    """
+class CSVChoicesMixin:
     STATIC_CHOICES = True
 
     def __init__(self, *, choices=(), **kwargs):
@@ -274,6 +272,25 @@ class CSVChoiceField(forms.ChoiceField):
         self.choices = unpack_grouped_choices(choices)
 
 
+class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField):
+    """
+    A CSV field which accepts a single selection value.
+    """
+    pass
+
+
+class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField):
+    """
+    A CSV field which accepts multiple selection values.
+    """
+    def to_python(self, value):
+        if not value:
+            return []
+        if not isinstance(value, str):
+            raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}")
+        return value.split(',')
+
+
 class CSVTypedChoiceField(forms.TypedChoiceField):
     STATIC_CHOICES = True
 

+ 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.

+ 10 - 10
netbox/utilities/tables/columns.py

@@ -130,7 +130,7 @@ class ActionsColumn(tables.Column):
 
     def render(self, record, table, **kwargs):
         # Skip dummy records (e.g. available VLANs) or those with no actions
-        if not hasattr(record, 'pk') or not self.actions:
+        if not getattr(record, 'pk', None) or not self.actions:
             return ''
 
         model = table.Meta.model
@@ -236,15 +236,15 @@ class ColoredLabelColumn(tables.TemplateColumn):
     Render a colored label (e.g. for DeviceRoles).
     """
     template_code = """
-    {% load helpers %}
-    {% if value %}
-    <span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
-      {{ value }}
-    </span>
-    {% else %}
-    &mdash;
-    {% endif %}
-    """
+{% load helpers %}
+  {% if value %}
+  <span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
+    <a href="{{ value.get_absolute_url }}">{{ value }}</a>
+  </span>
+{% else %}
+  &mdash;
+{% endif %}
+"""
 
     def __init__(self, *args, **kwargs):
         super().__init__(template_code=self.template_code, *args, **kwargs)

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

+ 5 - 3
netbox/utilities/validators.py

@@ -20,11 +20,13 @@ class EnhancedURLValidator(URLValidator):
         r'(?::\d{2,5})?'                    # Port number
         r'(?:[/?#][^\s]*)?'                 # Path
         r'\Z', re.IGNORECASE)
+    schemes = None
 
-    def __init__(self, schemes=None, **kwargs):
-        super().__init__(**kwargs)
-        if schemes is not None:
+    def __call__(self, value):
+        if self.schemes is None:
+            # We can't load the allowed schemes until the configuration has been initialized
             self.schemes = get_config().ALLOWED_URL_SCHEMES
+        return super().__call__(value)
 
 
 class ExclusionValidator(BaseValidator):

+ 6 - 0
netbox/virtualization/tables.py

@@ -79,6 +79,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

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff