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

Merge branch 'develop' into feature

Jeremy Stretch 2 лет назад
Родитель
Сommit
837be4d45f
60 измененных файлов с 457 добавлено и 209 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 8 6
      README.md
  4. 3 0
      docs/plugins/development/models.md
  5. 48 1
      docs/release-notes/version-3.5.md
  6. 9 3
      netbox/core/api/schema.py
  7. 10 8
      netbox/dcim/api/serializers.py
  8. 2 0
      netbox/dcim/choices.py
  9. 7 4
      netbox/dcim/filtersets.py
  10. 1 1
      netbox/dcim/forms/bulk_edit.py
  11. 2 2
      netbox/dcim/forms/bulk_import.py
  12. 13 0
      netbox/dcim/views.py
  13. 1 1
      netbox/extras/api/views.py
  14. 2 4
      netbox/extras/dashboard/widgets.py
  15. 0 7
      netbox/extras/forms/scripts.py
  16. 2 2
      netbox/extras/models/change_logging.py
  17. 4 5
      netbox/extras/models/models.py
  18. 5 2
      netbox/extras/models/reports.py
  19. 20 0
      netbox/extras/querysets.py
  20. 6 1
      netbox/extras/scripts.py
  21. 2 1
      netbox/extras/tests/test_api.py
  22. 4 4
      netbox/extras/views.py
  23. 2 1
      netbox/ipam/api/serializers.py
  24. 3 3
      netbox/ipam/api/views.py
  25. 7 0
      netbox/ipam/filtersets.py
  26. 2 1
      netbox/ipam/forms/model_forms.py
  27. 3 0
      netbox/ipam/models/asns.py
  28. 1 1
      netbox/ipam/models/ip.py
  29. 3 1
      netbox/ipam/models/vlans.py
  30. 38 1
      netbox/ipam/querysets.py
  31. 4 5
      netbox/ipam/tables/asn.py
  32. 28 3
      netbox/ipam/tables/ip.py
  33. 6 2
      netbox/ipam/tables/vlans.py
  34. 9 18
      netbox/ipam/views.py
  35. 1 1
      netbox/netbox/api/authentication.py
  36. 4 1
      netbox/netbox/middleware.py
  37. 1 1
      netbox/netbox/settings.py
  38. 1 1
      netbox/netbox/views/generic/bulk_views.py
  39. 0 0
      netbox/project-static/dist/netbox.js
  40. 0 0
      netbox/project-static/dist/netbox.js.map
  41. 1 1
      netbox/project-static/src/clipboard.ts
  42. 2 6
      netbox/templates/core/datafile.html
  43. 4 2
      netbox/templates/dcim/device.html
  44. 4 5
      netbox/templates/dcim/inc/cable_termination.html
  45. 12 2
      netbox/templates/dcim/virtualdevicecontext.html
  46. 66 60
      netbox/templates/extras/report_list.html
  47. 5 11
      netbox/templates/extras/script.html
  48. 1 1
      netbox/templates/extras/script_list.html
  49. 4 0
      netbox/templates/ipam/vlangroup.html
  50. 5 3
      netbox/templates/tenancy/object_contacts.html
  51. 2 4
      netbox/templates/users/api_token.html
  52. 4 2
      netbox/templates/virtualization/virtualmachine.html
  53. 5 0
      netbox/tenancy/models/contacts.py
  54. 32 2
      netbox/tenancy/tables/contacts.py
  55. 1 3
      netbox/users/tables.py
  56. 3 1
      netbox/users/views.py
  57. 3 0
      netbox/utilities/templates/builtins/copy_content.html
  58. 15 2
      netbox/utilities/templatetags/builtins/tags.py
  59. 15 1
      netbox/utilities/utils.py
  60. 9 9
      requirements.txt

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

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

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

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

+ 8 - 6
README.md

@@ -52,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations.
 ## Project Stats
 ## Project Stats
 
 
 <div align="center">
 <div align="center">
-  <a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
-  <a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
-  <a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
-  <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
+  <a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
+  <a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
+  <a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
+  <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
   <br />Stats via <a href="https://repography.com">Repography</a>
   <br />Stats via <a href="https://repography.com">Repography</a>
 </div>
 </div>
 
 
@@ -66,10 +66,12 @@ as the cornerstone for network automation in thousands of organizations.
   [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
   [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
   [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
   [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
-  <br />
-  [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+  [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
+  <br />
   [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
   [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
+  &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+  [![OneMind Services](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/onemind_services.png)](https://onemindservices.com)
 
 
 </div>
 </div>
 
 

+ 3 - 0
docs/plugins/development/models.md

@@ -19,6 +19,9 @@ class MyModel(models.Model):
 
 
 Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
 Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
 
 
+!!! note
+    Model names should adhere to [PEP8](https://www.python.org/dev/peps/pep-0008/#class-names) standards and be CapWords (no underscores).  Using underscores in model names will result in problems with permissions.
+
 ## Enabling NetBox Features
 ## Enabling NetBox Features
 
 
 Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
 Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:

+ 48 - 1
docs/release-notes/version-3.5.md

@@ -1,6 +1,53 @@
 # NetBox v3.5
 # NetBox v3.5
 
 
-## v3.5.5 (FUTURE)
+## v3.5.7 (FUTURE)
+
+---
+
+## v3.5.6 (2023-07-10)
+
+### Bug Fixes
+
+* [#13061](https://github.com/netbox-community/netbox/issues/13061) - Fix display of last result for scripts & reports with a custom name defined
+* [#13096](https://github.com/netbox-community/netbox/issues/13096) - Hide scheduling fields for all scripts with scheduling disabled
+* [#13105](https://github.com/netbox-community/netbox/issues/13105) - Fix exception when attempting to allocate next available IP address from prefix marked as utilized
+* [#13116](https://github.com/netbox-community/netbox/issues/13116) - Catch ProgrammingError exception when starting NetBox without pre-populated content types
+
+---
+
+## v3.5.5 (2023-07-06)
+
+### Enhancements
+
+* [#11738](https://github.com/netbox-community/netbox/issues/11738) - Annotate VLAN group utilization
+* [#12499](https://github.com/netbox-community/netbox/issues/12499) - Add "copy to clipboard" buttons in UI for IP addresses
+* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type
+* [#12955](https://github.com/netbox-community/netbox/issues/12955) - Include additional contact details on contact assignments table
+* [#13065](https://github.com/netbox-community/netbox/issues/13065) - Associate contact assignments with their objects in the change log
+
+### Bug Fixes
+
+* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Exclude stale content types when retrieving changelog records
+* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes
+* [#12579](https://github.com/netbox-community/netbox/issues/12579) - Fix exception when clicking "create and add another" to add a cable
+* [#12617](https://github.com/netbox-community/netbox/issues/12617) - Populate prechange snapshot on parent object when assigning/removing primary IP address
+* [#12760](https://github.com/netbox-community/netbox/issues/12760) - Avoid rendering partial HTMX responses when restoring browser tabs
+* [#12842](https://github.com/netbox-community/netbox/issues/12842) - Improve handling of exceptions when loading reports
+* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients
+* [#12951](https://github.com/netbox-community/netbox/issues/12951) - Display consistent parent information for each termination under cable view
+* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment
+* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields
+* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs
+* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled
+* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer
+* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets
+* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit
+* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types
+* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links
+* [#13047](https://github.com/netbox-community/netbox/issues/13047) - Correct ASN count under ASN ranges list
+* [#13056](https://github.com/netbox-community/netbox/issues/13056) - Add `config_template` field to device API serializer
+* [#13092](https://github.com/netbox-community/netbox/issues/13092) - Allow nullifying power port max & allocated draw values during bulk edit
+* [#13100](https://github.com/netbox-community/netbox/issues/13100) - Fix ValueError exception when searching for virtual device context for non-numeric values
 
 
 ---
 ---
 
 

+ 9 - 3
netbox/core/api/schema.py

@@ -1,5 +1,6 @@
 import re
 import re
 import typing
 import typing
+from collections import OrderedDict
 
 
 from drf_spectacular.extensions import OpenApiSerializerFieldExtension
 from drf_spectacular.extensions import OpenApiSerializerFieldExtension
 from drf_spectacular.openapi import AutoSchema
 from drf_spectacular.openapi import AutoSchema
@@ -28,14 +29,19 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
     target_class = 'netbox.api.fields.ChoiceField'
     target_class = 'netbox.api.fields.ChoiceField'
 
 
     def map_serializer_field(self, auto_schema, direction):
     def map_serializer_field(self, auto_schema, direction):
+        build_cf = build_choice_field(self.target)
+
         if direction == 'request':
         if direction == 'request':
-            return build_choice_field(self.target)
+            return build_cf
 
 
         elif direction == "response":
         elif direction == "response":
+            value = build_cf
+            label = {**build_basic_type(OpenApiTypes.STR), "enum": list(OrderedDict.fromkeys(self.target.choices.values()))}
+
             return build_object_type(
             return build_object_type(
                 properties={
                 properties={
-                    "value": build_basic_type(OpenApiTypes.STR),
-                    "label": build_basic_type(OpenApiTypes.STR),
+                    "value": value,
+                    "label": label
                 }
                 }
             )
             )
 
 

+ 10 - 8
netbox/dcim/api/serializers.py

@@ -699,7 +699,8 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
             'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
             'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
             'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
-            'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
+            'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template',
+            'created', 'last_updated',
         ]
         ]
 
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     @extend_schema_field(serializers.JSONField(allow_null=True))
@@ -708,7 +709,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
 
 
 
 
 class VirtualDeviceContextSerializer(NetBoxModelSerializer):
 class VirtualDeviceContextSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
     tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
     primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
     primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
@@ -881,12 +882,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
     parent = NestedInterfaceSerializer(required=False, allow_null=True)
     parent = NestedInterfaceSerializer(required=False, allow_null=True)
     bridge = NestedInterfaceSerializer(required=False, allow_null=True)
     bridge = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
-    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True)
+    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
     duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
     duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
-    rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True)
-    rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True)
-    poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True)
-    poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True)
+    rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
+    rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
+    poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
+    poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
@@ -908,9 +909,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
     mac_address = serializers.CharField(
     mac_address = serializers.CharField(
         required=False,
         required=False,
         default=None,
         default=None,
+        allow_blank=True,
         allow_null=True
         allow_null=True
     )
     )
-    wwn = serializers.CharField(required=False, default=None)
+    wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface

+ 2 - 0
netbox/dcim/choices.py

@@ -810,6 +810,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_100GE_CXP = '100gbase-x-cxp'
     TYPE_100GE_CXP = '100gbase-x-cxp'
     TYPE_100GE_CPAK = '100gbase-x-cpak'
     TYPE_100GE_CPAK = '100gbase-x-cpak'
     TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
     TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
+    TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
     TYPE_200GE_CFP2 = '200gbase-x-cfp2'
     TYPE_200GE_CFP2 = '200gbase-x-cfp2'
     TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
     TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
     TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
     TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
@@ -959,6 +960,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_100GE_CXP, 'CXP (100GE)'),
                 (TYPE_100GE_CXP, 'CXP (100GE)'),
                 (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
                 (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
                 (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
                 (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
+                (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
                 (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
                 (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
                 (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
                 (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
                 (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
                 (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),

+ 7 - 4
netbox/dcim/filtersets.py

@@ -1077,10 +1077,13 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
-        return queryset.filter(
-            Q(name__icontains=value) |
-            Q(identifier=value.strip())
-        ).distinct()
+
+        qs_filter = Q(name__icontains=value)
+        try:
+            qs_filter |= Q(identifier=int(value))
+        except ValueError:
+            pass
+        return queryset.filter(qs_filter).distinct()
 
 
     def _has_primary_ip(self, queryset, name, value):
     def _has_primary_ip(self, queryset, name, value):
         params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
         params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)

+ 1 - 1
netbox/dcim/forms/bulk_edit.py

@@ -1102,7 +1102,7 @@ class PowerPortBulkEditForm(
         (None, ('module', 'type', 'label', 'description', 'mark_connected')),
         (None, ('module', 'type', 'label', 'description', 'mark_connected')),
         ('Power', ('maximum_draw', 'allocated_draw')),
         ('Power', ('maximum_draw', 'allocated_draw')),
     )
     )
-    nullable_fields = ('module', 'label', 'description')
+    nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw')
 
 
 
 
 class PowerOutletBulkEditForm(
 class PowerOutletBulkEditForm(

+ 2 - 2
netbox/dcim/forms/bulk_import.py

@@ -306,7 +306,7 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
             'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
-            'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
+            'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
         ]
         ]
 
 
 
 
@@ -327,7 +327,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
 
 
     class Meta:
     class Meta:
         model = ModuleType
         model = ModuleType
-        fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
+        fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags']
 
 
 
 
 class DeviceRoleImportForm(NetBoxModelImportForm):
 class DeviceRoleImportForm(NetBoxModelImportForm):

+ 13 - 0
netbox/dcim/views.py

@@ -3131,6 +3131,19 @@ class CableEditView(generic.ObjectEditView):
 
 
         return obj
         return obj
 
 
+    def get_extra_addanother_params(self, request):
+
+        params = {
+            'a_terminations_type': request.GET.get('a_terminations_type'),
+            'b_terminations_type': request.GET.get('b_terminations_type')
+        }
+
+        for key in request.POST:
+            if 'device' in key or 'power_panel' in key or 'circuit' in key:
+                params.update({key: request.POST.get(key)})
+
+        return params
+
 
 
 @register_model_view(Cable, 'delete')
 @register_model_view(Cable, 'delete')
 class CableDeleteView(generic.ObjectDeleteView):
 class CableDeleteView(generic.ObjectDeleteView):

+ 1 - 1
netbox/extras/api/views.py

@@ -379,7 +379,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     Retrieve a list of recent changes.
     Retrieve a list of recent changes.
     """
     """
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
-    queryset = ObjectChange.objects.prefetch_related('user')
+    queryset = ObjectChange.objects.valid_models().prefetch_related('user')
     serializer_class = serializers.ObjectChangeSerializer
     serializer_class = serializers.ObjectChangeSerializer
     filterset_class = filtersets.ObjectChangeFilterSet
     filterset_class = filtersets.ObjectChangeFilterSet
 
 

+ 2 - 4
netbox/extras/dashboard/widgets.py

@@ -10,7 +10,6 @@ from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.cache import cache
 from django.core.cache import cache
 from django.db.models import Q
 from django.db.models import Q
-from django.http import QueryDict
 from django.template.loader import render_to_string
 from django.template.loader import render_to_string
 from django.urls import NoReverseMatch, resolve, reverse
 from django.urls import NoReverseMatch, resolve, reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
@@ -20,7 +19,7 @@ from extras.utils import FeatureQuery
 from utilities.forms import BootstrapMixin
 from utilities.forms import BootstrapMixin
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 from utilities.templatetags.builtins.filters import render_markdown
 from utilities.templatetags.builtins.filters import render_markdown
-from utilities.utils import content_type_identifier, content_type_name, get_viewname
+from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname
 from .utils import register_widget
 from .utils import register_widget
 
 
 __all__ = (
 __all__ = (
@@ -172,8 +171,7 @@ class ObjectCountsWidget(DashboardWidget):
                 qs = model.objects.restrict(request.user, 'view')
                 qs = model.objects.restrict(request.user, 'view')
                 # Apply any specified filters
                 # Apply any specified filters
                 if filters := self.config.get('filters'):
                 if filters := self.config.get('filters'):
-                    params = QueryDict(mutable=True)
-                    params.update(filters)
+                    params = dict_to_querydict(filters)
                     filterset = getattr(resolve(url).func.view_class, 'filterset', None)
                     filterset = getattr(resolve(url).func.view_class, 'filterset', None)
                     qs = filterset(params, qs).qs
                     qs = filterset(params, qs).qs
                     url = f'{url}?{params.urlencode()}'
                     url = f'{url}?{params.urlencode()}'

+ 0 - 7
netbox/extras/forms/scripts.py

@@ -56,10 +56,3 @@ class ScriptForm(BootstrapMixin, forms.Form):
             self.cleaned_data['_schedule_at'] = local_now()
             self.cleaned_data['_schedule_at'] = local_now()
 
 
         return self.cleaned_data
         return self.cleaned_data
-
-    @property
-    def requires_input(self):
-        """
-        A boolean indicating whether the form requires user input (ignore the built-in fields).
-        """
-        return bool(len(self.fields) > 3)

+ 2 - 2
netbox/extras/models/change_logging.py

@@ -5,7 +5,7 @@ from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 
 
 from extras.choices import *
 from extras.choices import *
-from utilities.querysets import RestrictedQuerySet
+from ..querysets import ObjectChangeQuerySet
 
 
 __all__ = (
 __all__ = (
     'ObjectChange',
     'ObjectChange',
@@ -82,7 +82,7 @@ class ObjectChange(models.Model):
         null=True
         null=True
     )
     )
 
 
-    objects = RestrictedQuerySet.as_manager()
+    objects = ObjectChangeQuerySet.as_manager()
 
 
     class Meta:
     class Meta:
         ordering = ['-time']
         ordering = ['-time']

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

@@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.cache import cache
 from django.core.cache import cache
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
-from django.http import HttpResponse, QueryDict
+from django.http import HttpResponse
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.formats import date_format
 from django.utils.formats import date_format
@@ -25,7 +25,7 @@ from netbox.models.features import (
     CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
     CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
 )
 )
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.utils import clean_html, render_jinja2
+from utilities.utils import clean_html, dict_to_querydict, render_jinja2
 
 
 __all__ = (
 __all__ = (
     'Bookmark',
     'Bookmark',
@@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         text = clean_html(text, allowed_schemes)
         text = clean_html(text, allowed_schemes)
 
 
         # Sanitize link
         # Sanitize link
-        link = urllib.parse.quote(link, safe='/:?&=%+[]@#')
+        link = urllib.parse.quote(link, safe='/:?&=%+[]@#,')
 
 
         # Verify link scheme is allowed
         # Verify link scheme is allowed
         result = urllib.parse.urlparse(link)
         result = urllib.parse.urlparse(link)
@@ -463,8 +463,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
 
     @property
     @property
     def url_params(self):
     def url_params(self):
-        qd = QueryDict(mutable=True)
-        qd.update(self.parameters)
+        qd = dict_to_querydict(self.parameters)
         return qd.urlencode()
         return qd.urlencode()
 
 
 
 

+ 5 - 2
netbox/extras/models/reports.py

@@ -1,7 +1,7 @@
 import inspect
 import inspect
+import logging
 from functools import cached_property
 from functools import cached_property
 
 
-from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 
 
@@ -12,6 +12,8 @@ from netbox.models.features import JobsMixin, WebhooksMixin
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from .mixins import PythonModuleMixin
 from .mixins import PythonModuleMixin
 
 
+logger = logging.getLogger('netbox.reports')
+
 __all__ = (
 __all__ = (
     'Report',
     'Report',
     'ReportModule',
     'ReportModule',
@@ -56,7 +58,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
 
 
         try:
         try:
             module = self.get_module()
             module = self.get_module()
-        except ImportError:
+        except (ImportError, SyntaxError) as e:
+            logger.error(f"Unable to load report module {self.name}, exception: {e}")
             return {}
             return {}
         reports = {}
         reports = {}
         ordered = getattr(module, 'report_order', [])
         ordered = getattr(module, 'report_order', [])

+ 20 - 0
netbox/extras/querysets.py

@@ -1,5 +1,8 @@
+from django.apps import apps
+from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.aggregates import JSONBAgg
 from django.contrib.postgres.aggregates import JSONBAgg
 from django.db.models import OuterRef, Subquery, Q
 from django.db.models import OuterRef, Subquery, Q
+from django.db.utils import ProgrammingError
 
 
 from extras.models.tags import TaggedItem
 from extras.models.tags import TaggedItem
 from utilities.query_functions import EmptyGroupByJSONBAgg
 from utilities.query_functions import EmptyGroupByJSONBAgg
@@ -151,3 +154,20 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         )
         )
 
 
         return base_query
         return base_query
+
+
+class ObjectChangeQuerySet(RestrictedQuerySet):
+
+    def valid_models(self):
+        # Exclude any change records which refer to an instance of a model that's no longer installed. This
+        # can happen when a plugin is removed but its data remains in the database, for example.
+        try:
+            content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
+        except ProgrammingError:
+            # Handle the case where the database schema has not yet been initialized
+            content_types = ContentType.objects.none()
+
+        content_type_ids = set(
+            ct.pk for ct in content_types
+        )
+        return self.filter(changed_object_type_id__in=content_type_ids)

+ 6 - 1
netbox/extras/scripts.py

@@ -366,7 +366,7 @@ class BaseScript:
         if self.fieldsets:
         if self.fieldsets:
             fieldsets.extend(self.fieldsets)
             fieldsets.extend(self.fieldsets)
         else:
         else:
-            fields = (name for name, _ in self._get_vars().items())
+            fields = list(name for name, _ in self._get_vars().items())
             fieldsets.append(('Script Data', fields))
             fieldsets.append(('Script Data', fields))
 
 
         # Append the default fieldset if defined in the Meta class
         # Append the default fieldset if defined in the Meta class
@@ -390,6 +390,11 @@ class BaseScript:
         # Set initial "commit" checkbox state based on the script's Meta parameter
         # Set initial "commit" checkbox state based on the script's Meta parameter
         form.fields['_commit'].initial = self.commit_default
         form.fields['_commit'].initial = self.commit_default
 
 
+        # Hide fields if scheduling has been disabled
+        if not self.scheduling_enabled:
+            form.fields['_schedule_at'].widget = forms.HiddenInput()
+            form.fields['_interval'].widget = forms.HiddenInput()
+
         return form
         return form
 
 
     # Logging
     # Logging

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

@@ -8,7 +8,6 @@ from rest_framework import status
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
-from extras.api.views import ReportViewSet, ScriptViewSet
 from extras.models import *
 from extras.models import *
 from extras.reports import Report
 from extras.reports import Report
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
@@ -634,6 +633,7 @@ class ReportTest(APITestCase):
         super().setUp()
         super().setUp()
 
 
         # Monkey-patch the API viewset's _get_report() method to return our test Report above
         # Monkey-patch the API viewset's _get_report() method to return our test Report above
+        from extras.api.views import ReportViewSet
         ReportViewSet._get_report = self.get_test_report
         ReportViewSet._get_report = self.get_test_report
 
 
     def test_get_report(self):
     def test_get_report(self):
@@ -676,6 +676,7 @@ class ScriptTest(APITestCase):
         super().setUp()
         super().setUp()
 
 
         # Monkey-patch the API viewset's _get_script() method to return our test Script above
         # Monkey-patch the API viewset's _get_script() method to return our test Script above
+        from extras.api.views import ScriptViewSet
         ScriptViewSet._get_script = self.get_test_script
         ScriptViewSet._get_script = self.get_test_script
 
 
     def test_get_script(self):
     def test_get_script(self):

+ 4 - 4
netbox/extras/views.py

@@ -541,7 +541,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
 #
 #
 
 
 class ObjectChangeListView(generic.ObjectListView):
 class ObjectChangeListView(generic.ObjectListView):
-    queryset = ObjectChange.objects.all()
+    queryset = ObjectChange.objects.valid_models()
     filterset = filtersets.ObjectChangeFilterSet
     filterset = filtersets.ObjectChangeFilterSet
     filterset_form = forms.ObjectChangeFilterForm
     filterset_form = forms.ObjectChangeFilterForm
     table = tables.ObjectChangeTable
     table = tables.ObjectChangeTable
@@ -551,10 +551,10 @@ class ObjectChangeListView(generic.ObjectListView):
 
 
 @register_model_view(ObjectChange)
 @register_model_view(ObjectChange)
 class ObjectChangeView(generic.ObjectView):
 class ObjectChangeView(generic.ObjectView):
-    queryset = ObjectChange.objects.all()
+    queryset = ObjectChange.objects.valid_models()
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
-        related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
+        related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
             request_id=instance.request_id
             request_id=instance.request_id
         ).exclude(
         ).exclude(
             pk=instance.pk
             pk=instance.pk
@@ -564,7 +564,7 @@ class ObjectChangeView(generic.ObjectView):
             orderable=False
             orderable=False
         )
         )
 
 
-        objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
+        objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
             changed_object_type=instance.changed_object_type,
             changed_object_type=instance.changed_object_type,
             changed_object_id=instance.changed_object_id,
             changed_object_id=instance.changed_object_id,
         )
         )

+ 2 - 1
netbox/ipam/api/serializers.py

@@ -219,12 +219,13 @@ class VLANGroupSerializer(NetBoxModelSerializer):
     scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
     scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
     scope = serializers.SerializerMethodField(read_only=True)
     scope = serializers.SerializerMethodField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
+    utilization = serializers.CharField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
             'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
-            'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count',
+            'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
         ]
         ]
         validators = []
         validators = []
 
 

+ 3 - 3
netbox/ipam/api/views.py

@@ -1,5 +1,7 @@
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
 from django.db import transaction
+from django.db.models import F
+from django.db.models.functions import Round
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from django_pglocks import advisory_lock
 from django_pglocks import advisory_lock
 from drf_spectacular.utils import extend_schema
 from drf_spectacular.utils import extend_schema
@@ -149,9 +151,7 @@ class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
 
 
 
 
 class VLANGroupViewSet(NetBoxModelViewSet):
 class VLANGroupViewSet(NetBoxModelViewSet):
-    queryset = VLANGroup.objects.annotate(
-        vlan_count=count_related(VLAN, 'group')
-    ).prefetch_related('tags')
+    queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
     serializer_class = serializers.VLANGroupSerializer
     serializer_class = serializers.VLANGroupSerializer
     filterset_class = filtersets.VLANGroupFilterSet
     filterset_class = filtersets.VLANGroupFilterSet
 
 

+ 7 - 0
netbox/ipam/filtersets.py

@@ -4,6 +4,8 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
+from drf_spectacular.utils import extend_schema_field
+from drf_spectacular.types import OpenApiTypes
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
 from dcim.models import Device, Interface, Region, Site, SiteGroup
 from dcim.models import Device, Interface, Region, Site, SiteGroup
@@ -414,6 +416,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         except (AddrFormatError, ValueError):
         except (AddrFormatError, ValueError):
             return queryset.none()
             return queryset.none()
 
 
+    @extend_schema_field(OpenApiTypes.STR)
     def filter_present_in_vrf(self, queryset, name, vrf):
     def filter_present_in_vrf(self, queryset, name, vrf):
         if vrf is None:
         if vrf is None:
             return queryset.none
             return queryset.none
@@ -659,6 +662,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
             return queryset
             return queryset
         return queryset.filter(address__net_mask_length=value)
         return queryset.filter(address__net_mask_length=value)
 
 
+    @extend_schema_field(OpenApiTypes.STR)
     def filter_present_in_vrf(self, queryset, name, vrf):
     def filter_present_in_vrf(self, queryset, name, vrf):
         if vrf is None:
         if vrf is None:
             return queryset.none
             return queryset.none
@@ -727,6 +731,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
             Q(name__icontains=value)
             Q(name__icontains=value)
         )
         )
 
 
+    @extend_schema_field(OpenApiTypes.STR)
     def filter_related_ip(self, queryset, name, value):
     def filter_related_ip(self, queryset, name, value):
         """
         """
         Filter by VRF & prefix of assigned IP addresses.
         Filter by VRF & prefix of assigned IP addresses.
@@ -941,9 +946,11 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
             pass
             pass
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_for_device(self, queryset, name, value):
     def get_for_device(self, queryset, name, value):
         return queryset.get_for_device(value)
         return queryset.get_for_device(value)
 
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_for_virtualmachine(self, queryset, name, value):
     def get_for_virtualmachine(self, queryset, name, value):
         return queryset.get_for_virtualmachine(value)
         return queryset.get_for_virtualmachine(value)
 
 

+ 2 - 1
netbox/ipam/forms/model_forms.py

@@ -345,7 +345,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
             })
             })
         elif selected_objects:
         elif selected_objects:
             assigned_object = self.cleaned_data[selected_objects[0]]
             assigned_object = self.cleaned_data[selected_objects[0]]
-            if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
+            if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
                 raise ValidationError(
                 raise ValidationError(
                     "Cannot reassign IP address while it is designated as the primary IP for the parent object"
                     "Cannot reassign IP address while it is designated as the primary IP for the parent object"
                 )
                 )
@@ -379,6 +379,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
         interface = self.instance.assigned_object
         interface = self.instance.assigned_object
         if type(interface) in (Interface, VMInterface):
         if type(interface) in (Interface, VMInterface):
             parent = interface.parent_object
             parent = interface.parent_object
+            parent.snapshot()
             if self.cleaned_data['primary_for_parent']:
             if self.cleaned_data['primary_for_parent']:
                 if ipaddress.address.version == 4:
                 if ipaddress.address.version == 4:
                     parent.primary_ip4 = ipaddress
                     parent.primary_ip4 = ipaddress

+ 3 - 0
netbox/ipam/models/asns.py

@@ -4,6 +4,7 @@ from django.urls import reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from ipam.fields import ASNField
 from ipam.fields import ASNField
+from ipam.querysets import ASNRangeQuerySet
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 
 
 __all__ = (
 __all__ = (
@@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel):
         null=True
         null=True
     )
     )
 
 
+    objects = ASNRangeQuerySet.as_manager()
+
     class Meta:
     class Meta:
         ordering = ('name',)
         ordering = ('name',)
         verbose_name = 'ASN range'
         verbose_name = 'ASN range'

+ 1 - 1
netbox/ipam/models/ip.py

@@ -406,7 +406,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
         Return all available IPs within this prefix as an IPSet.
         Return all available IPs within this prefix as an IPSet.
         """
         """
         if self.mark_utilized:
         if self.mark_utilized:
-            return list()
+            return netaddr.IPSet()
 
 
         prefix = netaddr.IPSet(self.prefix)
         prefix = netaddr.IPSet(self.prefix)
         child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
         child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])

+ 3 - 1
netbox/ipam/models/vlans.py

@@ -9,7 +9,7 @@ from django.utils.translation import gettext as _
 from dcim.models import Interface
 from dcim.models import Interface
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
-from ipam.querysets import VLANQuerySet
+from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 
 
@@ -63,6 +63,8 @@ class VLANGroup(OrganizationalModel):
         help_text=_('Highest permissible ID of a child VLAN')
         help_text=_('Highest permissible ID of a child VLAN')
     )
     )
 
 
+    objects = VLANGroupQuerySet.as_manager()
+
     class Meta:
     class Meta:
         ordering = ('name', 'pk')  # Name may be non-unique
         ordering = ('name', 'pk')  # Name may be non-unique
         constraints = (
         constraints = (

+ 38 - 1
netbox/ipam/querysets.py

@@ -1,8 +1,34 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import Q
+from django.db.models import Count, F, OuterRef, Q, Subquery, Value
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
+from django.db.models.functions import Round
 
 
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
+from utilities.utils import count_related
+
+__all__ = (
+    'ASNRangeQuerySet',
+    'PrefixQuerySet',
+    'VLANQuerySet',
+)
+
+
+class ASNRangeQuerySet(RestrictedQuerySet):
+
+    def annotate_asn_counts(self):
+        """
+        Annotate the number of ASNs which appear within each range.
+        """
+        from .models import ASN
+
+        # Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value
+        # that we can use to count ASNs and return a single value per ASNRange.
+        asns = ASN.objects.filter(
+            asn__gte=OuterRef('start'),
+            asn__lte=OuterRef('end')
+        ).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c')
+
+        return self.annotate(asn_count=Subquery(asns))
 
 
 
 
 class PrefixQuerySet(RestrictedQuerySet):
 class PrefixQuerySet(RestrictedQuerySet):
@@ -30,6 +56,17 @@ class PrefixQuerySet(RestrictedQuerySet):
         )
         )
 
 
 
 
+class VLANGroupQuerySet(RestrictedQuerySet):
+
+    def annotate_utilization(self):
+        from .models import VLAN
+
+        return self.annotate(
+            vlan_count=count_related(VLAN, 'group'),
+            utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2)
+        )
+
+
 class VLANQuerySet(RestrictedQuerySet):
 class VLANQuerySet(RestrictedQuerySet):
 
 
     def get_for_device(self, device):
     def get_for_device(self, device):

+ 4 - 5
netbox/ipam/tables/asn.py

@@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='ipam:asnrange_list'
         url_name='ipam:asnrange_list'
     )
     )
-    asn_count = columns.LinkedCountColumn(
-        viewname='ipam:asn_list',
-        url_params={'asn_id': 'pk'},
-        verbose_name=_('ASN Count')
+    asn_count = tables.Column(
+        verbose_name=_('ASNs')
     )
     )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
@@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name=_('Provider Count')
         verbose_name=_('Provider Count')
     )
     )
     sites = columns.ManyToManyColumn(
     sites = columns.ManyToManyColumn(
-        linkify_item=True
+        linkify_item=True,
+        verbose_name=_('Sites')
     )
     )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
     tags = columns.TagColumn(
     tags = columns.TagColumn(

+ 28 - 3
netbox/ipam/tables/ip.py

@@ -19,14 +19,22 @@ __all__ = (
 
 
 AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
 AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
 
 
+AGGREGATE_COPY_BUTTON = """
+{% copy_content record.pk prefix="aggregate_" %}
+"""
+
 PREFIX_LINK = """
 PREFIX_LINK = """
 {% if record.pk %}
 {% if record.pk %}
-  <a href="{{ record.get_absolute_url }}">{{ record.prefix }}</a>
+  <a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
 {% else %}
 {% else %}
   <a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
   <a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
 {% endif %}
 {% endif %}
 """
 """
 
 
+PREFIX_COPY_BUTTON = """
+{% copy_content record.pk prefix="prefix_" %}
+"""
+
 PREFIX_LINK_WITH_DEPTH = """
 PREFIX_LINK_WITH_DEPTH = """
 {% load helpers %}
 {% load helpers %}
 {% if record.depth %}
 {% if record.depth %}
@@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """
 
 
 IPADDRESS_LINK = """
 IPADDRESS_LINK = """
 {% if record.pk %}
 {% if record.pk %}
-    <a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
+    <a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a>
 {% elif perms.ipam.add_ipaddress %}
 {% elif perms.ipam.add_ipaddress %}
     <a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
     <a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
 {% else %}
 {% else %}
@@ -48,6 +56,10 @@ IPADDRESS_LINK = """
 {% endif %}
 {% endif %}
 """
 """
 
 
+IPADDRESS_COPY_BUTTON = """
+{% copy_content record.pk prefix="ipaddress_" %}
+"""
+
 IPADDRESS_ASSIGN_LINK = """
 IPADDRESS_ASSIGN_LINK = """
 <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
 <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
 """
 """
@@ -99,7 +111,11 @@ class RIRTable(NetBoxTable):
 class AggregateTable(TenancyColumnsMixin, NetBoxTable):
 class AggregateTable(TenancyColumnsMixin, NetBoxTable):
     prefix = tables.Column(
     prefix = tables.Column(
         linkify=True,
         linkify=True,
-        verbose_name='Aggregate'
+        verbose_name='Aggregate',
+        attrs={
+            # Allow the aggregate to be copied to the clipboard
+            'a': {'id': lambda record: f"aggregate_{record.pk}"}
+        }
     )
     )
     date_added = tables.DateColumn(
     date_added = tables.DateColumn(
         format="Y-m-d",
         format="Y-m-d",
@@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable):
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='ipam:aggregate_list'
         url_name='ipam:aggregate_list'
     )
     )
+    actions = columns.ActionsColumn(
+        extra_buttons=AGGREGATE_COPY_BUTTON
+    )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Aggregate
         model = Aggregate
@@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='ipam:prefix_list'
         url_name='ipam:prefix_list'
     )
     )
+    actions = columns.ActionsColumn(
+        extra_buttons=PREFIX_COPY_BUTTON
+    )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Prefix
         model = Prefix
@@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='ipam:ipaddress_list'
         url_name='ipam:ipaddress_list'
     )
     )
+    actions = columns.ActionsColumn(
+        extra_buttons=IPADDRESS_COPY_BUTTON
+    )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = IPAddress
         model = IPAddress

+ 6 - 2
netbox/ipam/tables/vlans.py

@@ -70,6 +70,10 @@ class VLANGroupTable(NetBoxTable):
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
         verbose_name='VLANs'
         verbose_name='VLANs'
     )
     )
+    utilization = columns.UtilizationColumn(
+        orderable=False,
+        verbose_name='Utilization'
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='ipam:vlangroup_list'
         url_name='ipam:vlangroup_list'
     )
     )
@@ -81,9 +85,9 @@ class VLANGroupTable(NetBoxTable):
         model = VLANGroup
         model = VLANGroup
         fields = (
         fields = (
             'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
             'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
-            'tags', 'created', 'last_updated', 'actions',
+            'tags', 'created', 'last_updated', 'actions', 'utilization',
         )
         )
-        default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description')
+        default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description')
 
 
 
 
 #
 #

+ 9 - 18
netbox/ipam/views.py

@@ -1,6 +1,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import Prefetch
+from django.db.models import F, Prefetch
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
+from django.db.models.functions import Round
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
@@ -198,7 +199,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
 #
 #
 
 
 class ASNRangeListView(generic.ObjectListView):
 class ASNRangeListView(generic.ObjectListView):
-    queryset = ASNRange.objects.all()
+    queryset = ASNRange.objects.annotate_asn_counts()
     filterset = filtersets.ASNRangeFilterSet
     filterset = filtersets.ASNRangeFilterSet
     filterset_form = forms.ASNRangeFilterForm
     filterset_form = forms.ASNRangeFilterForm
     table = tables.ASNRangeTable
     table = tables.ASNRangeTable
@@ -247,18 +248,14 @@ class ASNRangeBulkImportView(generic.BulkImportView):
 
 
 
 
 class ASNRangeBulkEditView(generic.BulkEditView):
 class ASNRangeBulkEditView(generic.BulkEditView):
-    queryset = ASNRange.objects.annotate(
-        site_count=count_related(Site, 'asns')
-    )
+    queryset = ASNRange.objects.annotate_asn_counts()
     filterset = filtersets.ASNRangeFilterSet
     filterset = filtersets.ASNRangeFilterSet
     table = tables.ASNRangeTable
     table = tables.ASNRangeTable
     form = forms.ASNRangeBulkEditForm
     form = forms.ASNRangeBulkEditForm
 
 
 
 
 class ASNRangeBulkDeleteView(generic.BulkDeleteView):
 class ASNRangeBulkDeleteView(generic.BulkDeleteView):
-    queryset = ASNRange.objects.annotate(
-        site_count=count_related(Site, 'asns')
-    )
+    queryset = ASNRange.objects.annotate_asn_counts()
     filterset = filtersets.ASNRangeFilterSet
     filterset = filtersets.ASNRangeFilterSet
     table = tables.ASNRangeTable
     table = tables.ASNRangeTable
 
 
@@ -886,9 +883,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
 #
 #
 
 
 class VLANGroupListView(generic.ObjectListView):
 class VLANGroupListView(generic.ObjectListView):
-    queryset = VLANGroup.objects.annotate(
-        vlan_count=count_related(VLAN, 'group')
-    )
+    queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
     filterset = filtersets.VLANGroupFilterSet
     filterset = filtersets.VLANGroupFilterSet
     filterset_form = forms.VLANGroupFilterForm
     filterset_form = forms.VLANGroupFilterForm
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
@@ -896,7 +891,7 @@ class VLANGroupListView(generic.ObjectListView):
 
 
 @register_model_view(VLANGroup)
 @register_model_view(VLANGroup)
 class VLANGroupView(generic.ObjectView):
 class VLANGroupView(generic.ObjectView):
-    queryset = VLANGroup.objects.all()
+    queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         related_models = (
         related_models = (
@@ -938,18 +933,14 @@ class VLANGroupBulkImportView(generic.BulkImportView):
 
 
 
 
 class VLANGroupBulkEditView(generic.BulkEditView):
 class VLANGroupBulkEditView(generic.BulkEditView):
-    queryset = VLANGroup.objects.annotate(
-        vlan_count=count_related(VLAN, 'group')
-    )
+    queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
     filterset = filtersets.VLANGroupFilterSet
     filterset = filtersets.VLANGroupFilterSet
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
     form = forms.VLANGroupBulkEditForm
     form = forms.VLANGroupBulkEditForm
 
 
 
 
 class VLANGroupBulkDeleteView(generic.BulkDeleteView):
 class VLANGroupBulkDeleteView(generic.BulkDeleteView):
-    queryset = VLANGroup.objects.annotate(
-        vlan_count=count_related(VLAN, 'group')
-    )
+    queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
     filterset = filtersets.VLANGroupFilterSet
     filterset = filtersets.VLANGroupFilterSet
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
 
 

+ 1 - 1
netbox/netbox/api/authentication.py

@@ -60,7 +60,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
 
 
         user = token.user
         user = token.user
         # When LDAP authentication is active try to load user data from LDAP directory
         # When LDAP authentication is active try to load user data from LDAP directory
-        if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
+        if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND:
             from netbox.authentication import LDAPBackend
             from netbox.authentication import LDAPBackend
             ldap_backend = LDAPBackend()
             ldap_backend = LDAPBackend()
 
 

+ 4 - 1
netbox/netbox/middleware.py

@@ -49,6 +49,9 @@ class CoreMiddleware:
         # Attach the unique request ID as an HTTP header.
         # Attach the unique request ID as an HTTP header.
         response['X-Request-ID'] = request.id
         response['X-Request-ID'] = request.id
 
 
+        # Enable the Vary header to help with caching of HTMX responses
+        response['Vary'] = 'HX-Request'
+
         # If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5').
         # If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5').
         if is_api_request(request):
         if is_api_request(request):
             response['API-Version'] = settings.REST_FRAMEWORK_VERSION
             response['API-Version'] = settings.REST_FRAMEWORK_VERSION
@@ -203,7 +206,7 @@ class MaintenanceModeMiddleware:
         """
         """
         Prevent any write-related database operations if an exception is raised.
         Prevent any write-related database operations if an exception is raised.
         """
         """
-        if isinstance(exception, InternalError):
+        if get_config().MAINTENANCE_MODE and isinstance(exception, InternalError):
             error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
             error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
                             'operations. Please try again later.'
                             'operations. Please try again later.'
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.5.5-dev'
+VERSION = '3.5.7-dev'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 1 - 1
netbox/netbox/views/generic/bulk_views.py

@@ -551,7 +551,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
             for name, m2m_field in m2m_fields.items():
             for name, m2m_field in m2m_fields.items():
                 if name in form.nullable_fields and name in nullified_fields:
                 if name in form.nullable_fields and name in nullified_fields:
                     getattr(obj, name).clear()
                     getattr(obj, name).clear()
-                else:
+                elif form.cleaned_data[name]:
                     getattr(obj, name).set(form.cleaned_data[name])
                     getattr(obj, name).set(form.cleaned_data[name])
 
 
             # Add/remove tags
             # Add/remove tags

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


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


+ 1 - 1
netbox/project-static/src/clipboard.ts

@@ -2,7 +2,7 @@ import Clipboard from 'clipboard';
 import { getElements } from './util';
 import { getElements } from './util';
 
 
 export function initClipboard(): void {
 export function initClipboard(): void {
-  for (const element of getElements('a.copy-token', 'button.copy-secret')) {
+  for (const element of getElements('a.copy-content')) {
     new Clipboard(element);
     new Clipboard(element);
   }
   }
 }
 }

+ 2 - 6
netbox/templates/core/datafile.html

@@ -39,9 +39,7 @@
               <th scope="row">Path</th>
               <th scope="row">Path</th>
               <td>
               <td>
                 <span class="font-monospace" id="datafile_path">{{ object.path }}</span>
                 <span class="font-monospace" id="datafile_path">{{ object.path }}</span>
-                <a class="btn btn-sm btn-primary copy-token" data-clipboard-target="#datafile_path" title="Copy to clipboard">
-                  <i class="mdi mdi-content-copy"></i>
-                </a>
+                {% copy_content "datafile_path" %}
               </td>
               </td>
             </tr>
             </tr>
             <tr>
             <tr>
@@ -56,9 +54,7 @@
               <th scope="row">SHA256 Hash</th>
               <th scope="row">SHA256 Hash</th>
               <td>
               <td>
               <span class="font-monospace" id="datafile_hash">{{ object.hash }}</span>
               <span class="font-monospace" id="datafile_hash">{{ object.hash }}</span>
-                <a class="btn btn-sm btn-primary copy-token" data-clipboard-target="#datafile_hash" title="Copy to clipboard">
-                  <i class="mdi mdi-content-copy"></i>
-                </a>
+                {% copy_content "datafile_hash" %}
               </td>
               </td>
             </tr>
             </tr>
           </table>
           </table>

+ 4 - 2
netbox/templates/dcim/device.html

@@ -211,12 +211,13 @@
                             <th scope="row">Primary IPv4</th>
                             <th scope="row">Primary IPv4</th>
                             <td>
                             <td>
                               {% if object.primary_ip4 %}
                               {% if object.primary_ip4 %}
-                                <a href="{{ object.primary_ip4.get_absolute_url }}">{{ object.primary_ip4.address.ip }}</a>
+                                <a href="{{ object.primary_ip4.get_absolute_url }}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
                                 {% if object.primary_ip4.nat_inside %}
                                 {% if object.primary_ip4.nat_inside %}
                                   (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
                                   (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
                                 {% elif object.primary_ip4.nat_outside.exists %}
                                 {% elif object.primary_ip4.nat_outside.exists %}
                                   (NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                                   (NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                                 {% endif %}
                                 {% endif %}
+                                {% copy_content "primary_ip4" %}
                               {% else %}
                               {% else %}
                                 {{ ''|placeholder }}
                                 {{ ''|placeholder }}
                               {% endif %}
                               {% endif %}
@@ -226,12 +227,13 @@
                             <th scope="row">Primary IPv6</th>
                             <th scope="row">Primary IPv6</th>
                             <td>
                             <td>
                               {% if object.primary_ip6 %}
                               {% if object.primary_ip6 %}
-                                <a href="{{ object.primary_ip6.get_absolute_url }}">{{ object.primary_ip6.address.ip }}</a>
+                                <a href="{{ object.primary_ip6.get_absolute_url }}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
                                 {% if object.primary_ip6.nat_inside %}
                                 {% if object.primary_ip6.nat_inside %}
                                   (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
                                   (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
                                 {% elif object.primary_ip6.nat_outside.exists %}
                                 {% elif object.primary_ip6.nat_outside.exists %}
                                   (NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                                   (NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                                 {% endif %}
                                 {% endif %}
+                                {% copy_content "primary_ip6" %}
                               {% else %}
                               {% else %}
                                 {{ ''|placeholder }}
                                 {{ ''|placeholder }}
                               {% endif %}
                               {% endif %}

+ 4 - 5
netbox/templates/dcim/inc/cable_termination.html

@@ -15,15 +15,14 @@
         <td>Rack</td>
         <td>Rack</td>
         <td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
         <td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
       </tr>
       </tr>
-      <tr>
-        <td>Device</td>
-        <td>{{ terminations.0.device|linkify }}</td>
-      </tr>
       <tr>
       <tr>
         <td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
         <td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
         <td>
         <td>
           {% for term in terminations %}
           {% for term in terminations %}
-            {{ term|linkify }}{% if not forloop.last %},{% endif %}
+	    {{term.device|linkify}}
+	    <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
+	    {{ term|linkify }}
+	    {% if not forloop.last %}<br/>{% endif %}
           {% endfor %}
           {% endfor %}
         </td>
         </td>
       </tr>
       </tr>

+ 12 - 2
netbox/templates/dcim/virtualdevicecontext.html

@@ -31,13 +31,23 @@
           <tr>
           <tr>
             <th scope="row">Primary IPv4</th>
             <th scope="row">Primary IPv4</th>
             <td>
             <td>
-              {{ object.primary_ip4|linkify|placeholder }}
+              {% if object.primary_ip4 %}
+                <a href="{{ object.primary_ip4.get_absolute_url }}" id="primary_ip4">{{ object.primary_ip4 }}</a>
+                {% copy_content "primary_ip4" %}
+             {% else %}
+                <span class="text-muted">—</span>
+             {% endif %}
             </td>
             </td>
           </tr>
           </tr>
           <tr>
           <tr>
             <th scope="row">Primary IPv6</th>
             <th scope="row">Primary IPv6</th>
             <td>
             <td>
-              {{ object.primary_ip6|linkify|placeholder }}
+              {% if object.primary_ip6 %}
+                <a href="{{ object.primary_ip6.get_absolute_url }}" id="primary_ip6">{{ object.primary_ip6 }}</a>
+                {% copy_content "primary_ip6" %}
+             {% else %}
+                <span class="text-muted">—</span>
+             {% endif %}
             </td>
             </td>
           </tr>
           </tr>
           <tr>
           <tr>

+ 66 - 60
netbox/templates/extras/report_list.html

@@ -38,71 +38,77 @@
         </h5>
         </h5>
         <div class="card-body">
         <div class="card-body">
           {% include 'inc/sync_warning.html' with object=module %}
           {% include 'inc/sync_warning.html' with object=module %}
-          <table class="table table-hover table-headings reports">
-            <thead>
-              <tr>
-                <th width="250">Name</th>
-                <th>Description</th>
-                <th>Last Run</th>
-                <th>Status</th>
-                <th width="120"></th>
-              </tr>
-            </thead>
-            <tbody>
-              {% with jobs=module.get_latest_jobs %}
-                {% for report_name, report in module.reports.items %}
-                  {% with last_job=jobs|get_key:report.name %}
-                    <tr>
-                      <td>
-                        <a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
-                      </td>
-                      <td>{{ report.description|markdown|placeholder }}</td>
-                      {% if last_job %}
-                        <td>
-                          <a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
-                        </td>
+          {% if module.reports %}
+            <table class="table table-hover table-headings reports">
+              <thead>
+                <tr>
+                  <th width="250">Name</th>
+                  <th>Description</th>
+                  <th>Last Run</th>
+                  <th>Status</th>
+                  <th width="120"></th>
+                </tr>
+              </thead>
+              <tbody>
+                {% with jobs=module.get_latest_jobs %}
+                  {% for report_name, report in module.reports.items %}
+                    {% with last_job=jobs|get_key:report.class_name %}
+                      <tr>
                         <td>
                         <td>
-                          {% badge last_job.get_status_display last_job.get_status_color %}
+                          <a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
                         </td>
                         </td>
-                      {% else %}
-                        <td class="text-muted">Never</td>
-                        <td>{{ ''|placeholder }}</td>
-                      {% endif %}
-                      <td>
-                        {% 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 btn-sm" style="width: 110px">
-                                {% if last_job %}
-                                  <i class="mdi mdi-replay"></i> Run Again
-                                {% else %}
-                                  <i class="mdi mdi-play"></i> Run Report
-                                {% endif %}
-                              </button>
-                            </form>
-                          </div>
+                        <td>{{ report.description|markdown|placeholder }}</td>
+                        {% if last_job %}
+                          <td>
+                            <a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
+                          </td>
+                          <td>
+                            {% badge last_job.get_status_display last_job.get_status_color %}
+                          </td>
+                        {% else %}
+                          <td class="text-muted">Never</td>
+                          <td>{{ ''|placeholder }}</td>
                         {% endif %}
                         {% endif %}
-                      </td>
-                    </tr>
-                    {% for method, stats in last_job.data.items %}
-                      <tr>
-                        <td colspan="4" class="method">
-                          <span class="ps-3">{{ method }}</span>
-                        </td>
-                        <td class="text-end text-nowrap report-stats">
-                          <span class="badge bg-success">{{ stats.success }}</span>
-                          <span class="badge bg-info">{{ stats.info }}</span>
-                          <span class="badge bg-warning">{{ stats.warning }}</span>
-                          <span class="badge bg-danger">{{ stats.failure }}</span>
+                        <td>
+                          {% 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 btn-sm" style="width: 110px">
+                                  {% if last_job %}
+                                    <i class="mdi mdi-replay"></i> Run Again
+                                  {% else %}
+                                    <i class="mdi mdi-play"></i> Run Report
+                                  {% endif %}
+                                </button>
+                              </form>
+                            </div>
+                          {% endif %}
                         </td>
                         </td>
                       </tr>
                       </tr>
-                    {% endfor %}
-                  {% endwith %}
-                {% endfor %}
-              {% endwith %}
-            </tbody>
-          </table>
+                      {% for method, stats in last_job.data.items %}
+                        <tr>
+                          <td colspan="4" class="method">
+                            <span class="ps-3">{{ method }}</span>
+                          </td>
+                          <td class="text-end text-nowrap report-stats">
+                            <span class="badge bg-success">{{ stats.success }}</span>
+                            <span class="badge bg-info">{{ stats.info }}</span>
+                            <span class="badge bg-warning">{{ stats.warning }}</span>
+                            <span class="badge bg-danger">{{ stats.failure }}</span>
+                          </td>
+                        </tr>
+                      {% endfor %}
+                    {% endwith %}
+                  {% endfor %}
+                {% endwith %}
+              </tbody>
+            </table>
+          {% else %}
+            <div class="alert alert-warning" role="alert">
+              <i class="mdi mdi-alert"></i> Could not load reports from {{ module.name }}
+            </div>
+          {% endif %}
         </div>
         </div>
       </div>
       </div>
     {% empty %}
     {% empty %}

+ 5 - 11
netbox/templates/extras/script.html

@@ -15,9 +15,9 @@
       <form action="" method="post" enctype="multipart/form-data" class="form form-object-edit">
       <form action="" method="post" enctype="multipart/form-data" class="form form-object-edit">
         {% csrf_token %}
         {% csrf_token %}
         <div class="field-group my-4">
         <div class="field-group my-4">
-          {% if form.requires_input %}
-            {# Render grouped fields according to declared fieldsets #}
-            {% for group, fields in script.get_fieldsets %}
+          {# Render grouped fields according to declared fieldsets #}
+          {% for group, fields in script.get_fieldsets %}
+            {% if fields %}
               <div class="field-group mb-5">
               <div class="field-group mb-5">
                 <div class="row mb-2">
                 <div class="row mb-2">
                   <h5 class="offset-sm-3">{{ group }}</h5>
                   <h5 class="offset-sm-3">{{ group }}</h5>
@@ -28,14 +28,8 @@
                   {% endwith %}
                   {% endwith %}
                 {% endfor %}
                 {% endfor %}
               </div>
               </div>
-            {% endfor %}
-          {% else %}
-            <div class="alert alert-info">
-              <i class="mdi mdi-information"></i>
-              This script does not require any input to run.
-            </div>
-            {% render_form form %}
-          {% endif %}
+            {% endif %}
+          {% endfor %}
         </div>
         </div>
         <div class="float-end">
         <div class="float-end">
           <a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
           <a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>

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

@@ -61,7 +61,7 @@
                       <td>
                       <td>
                         {{ script_class.Meta.description|markdown|placeholder }}
                         {{ script_class.Meta.description|markdown|placeholder }}
                       </td>
                       </td>
-                      {% with last_result=jobs|get_key:script_class.name %}
+                      {% with last_result=jobs|get_key:script_class.class_name %}
                         {% if last_result %}
                         {% if last_result %}
                           <td>
                           <td>
                             <a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
                             <a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>

+ 4 - 0
netbox/templates/ipam/vlangroup.html

@@ -42,6 +42,10 @@
             <th scope="row">Permitted VIDs</th>
             <th scope="row">Permitted VIDs</th>
             <td>{{ object.min_vid }} - {{ object.max_vid }}</td>
             <td>{{ object.min_vid }} - {{ object.max_vid }}</td>
           </tr>
           </tr>
+          <tr>
+            <th scope="row">Utilization</th>
+            <td>{% utilization_graph object.utilization %}</td>
+          </tr>
         </table>
         </table>
       </div>
       </div>
     </div>
     </div>

+ 5 - 3
netbox/templates/tenancy/object_contacts.html

@@ -2,10 +2,12 @@
 {% load helpers %}
 {% load helpers %}
 
 
 {% block extra_controls %}
 {% block extra_controls %}
-    {% if perms.tenancy.add_contactassignment %}
-    <a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
+  {% if perms.tenancy.add_contactassignment %}
+    {% with viewname=object|viewname:"contacts" %}
+      <a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% url viewname pk=object.pk %}" class="btn btn-primary btn-sm">
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
-    </a>
+      </a>
+    {% endwith %}
   {% endif %}
   {% endif %}
 {% endblock %}
 {% endblock %}
 
 

+ 2 - 4
netbox/templates/users/api_token.html

@@ -8,7 +8,7 @@
     <div class="col col-md-12">
     <div class="col col-md-12">
       {% if not settings.ALLOW_TOKEN_RETRIEVAL %}
       {% if not settings.ALLOW_TOKEN_RETRIEVAL %}
         <div class="alert alert-danger" role="alert">
         <div class="alert alert-danger" role="alert">
-          <i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-token" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
+          <i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
         </div>
         </div>
       {% endif %}
       {% endif %}
       <div class="card">
       <div class="card">
@@ -19,9 +19,7 @@
               <th scope="row">Key</th>
               <th scope="row">Key</th>
               <td>
               <td>
                 <div class="float-end">
                 <div class="float-end">
-                  <a class="btn btn-sm btn-success copy-token" data-clipboard-target="#token_id" title="Copy to clipboard">
-                    <i class="mdi mdi-content-copy"></i>
-                  </a>
+                  {% copy_content "token_id" %}
                 </div>
                 </div>
                 <div id="token_id">{{ key }}</div>
                 <div id="token_id">{{ key }}</div>
               </td>
               </td>

+ 4 - 2
netbox/templates/virtualization/virtualmachine.html

@@ -46,12 +46,13 @@
                         <th scope="row">Primary IPv4</th>
                         <th scope="row">Primary IPv4</th>
                         <td>
                         <td>
                           {% if object.primary_ip4 %}
                           {% if object.primary_ip4 %}
-                            <a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
+                            <a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
                             {% if object.primary_ip4.nat_inside %}
                             {% if object.primary_ip4.nat_inside %}
                               (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
                               (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
                             {% elif object.primary_ip4.nat_outside.exists %}
                             {% elif object.primary_ip4.nat_outside.exists %}
                               (NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                               (NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                             {% endif %}
                             {% endif %}
+                            {% copy_content "primary_ip4" %}
                           {% else %}
                           {% else %}
                             {{ ''|placeholder }}
                             {{ ''|placeholder }}
                           {% endif %}
                           {% endif %}
@@ -61,12 +62,13 @@
                         <th scope="row">Primary IPv6</th>
                         <th scope="row">Primary IPv6</th>
                         <td>
                         <td>
                           {% if object.primary_ip6 %}
                           {% if object.primary_ip6 %}
-                            <a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a>
+                            <a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
                             {% if object.primary_ip6.nat_inside %}
                             {% if object.primary_ip6.nat_inside %}
                               (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
                               (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
                             {% elif object.primary_ip6.nat_outside.exists %}
                             {% elif object.primary_ip6.nat_outside.exists %}
                               (NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                               (NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                             {% endif %}
                             {% endif %}
+                            {% copy_content "primary_ip6" %}
                           {% else %}
                           {% else %}
                             {{ ''|placeholder }}
                             {{ ''|placeholder }}
                           {% endif %}
                           {% endif %}

+ 5 - 0
netbox/tenancy/models/contacts.py

@@ -136,3 +136,8 @@ class ContactAssignment(ChangeLoggedModel):
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('tenancy:contact', args=[self.contact.pk])
         return reverse('tenancy:contact', args=[self.contact.pk])
+
+    def to_objectchange(self, action):
+        objectchange = super().to_objectchange(action)
+        objectchange.related_object = self.object
+        return objectchange

+ 32 - 2
netbox/tenancy/tables/contacts.py

@@ -1,4 +1,5 @@
 import django_tables2 as tables
 import django_tables2 as tables
+from django_tables2.utils import Accessor
 
 
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
 from tenancy.models import *
 from tenancy.models import *
@@ -90,11 +91,40 @@ class ContactAssignmentTable(NetBoxTable):
     role = tables.Column(
     role = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    contact_title = tables.Column(
+        accessor=Accessor('contact__title'),
+        verbose_name='Contact Title'
+    )
+    contact_phone = tables.Column(
+        accessor=Accessor('contact__phone'),
+        verbose_name='Contact Phone'
+    )
+    contact_email = tables.Column(
+        accessor=Accessor('contact__email'),
+        verbose_name='Contact Email'
+    )
+    contact_address = tables.Column(
+        accessor=Accessor('contact__address'),
+        verbose_name='Contact Address'
+    )
+    contact_link = tables.Column(
+        accessor=Accessor('contact__link'),
+        verbose_name='Contact Link'
+    )
+    contact_description = tables.Column(
+        accessor=Accessor('contact__description'),
+        verbose_name='Contact Description'
+    )
     actions = columns.ActionsColumn(
     actions = columns.ActionsColumn(
         actions=('edit', 'delete')
         actions=('edit', 'delete')
     )
     )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = ContactAssignment
         model = ContactAssignment
-        fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions')
-        default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority')
+        fields = (
+            'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
+            'contact_email', 'contact_address', 'contact_link', 'contact_description', 'actions'
+        )
+        default_columns = (
+            'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
+        )

+ 1 - 3
netbox/users/tables.py

@@ -12,9 +12,7 @@ ALLOWED_IPS = """{{ value|join:", " }}"""
 
 
 COPY_BUTTON = """
 COPY_BUTTON = """
 {% if settings.ALLOW_TOKEN_RETRIEVAL %}
 {% if settings.ALLOW_TOKEN_RETRIEVAL %}
-  <a class="btn btn-sm btn-success copy-token" data-clipboard-target="#token_{{ record.pk }}" title="Copy to clipboard">
-    <i class="mdi mdi-content-copy"></i>
-  </a>
+  {% copy_content record.pk prefix="token_" color="success" %}
 {% endif %}
 {% endif %}
 """
 """
 
 

+ 3 - 1
netbox/users/views.py

@@ -160,7 +160,9 @@ class ProfileView(LoginRequiredMixin, View):
     def get(self, request):
     def get(self, request):
 
 
         # Compile changelog table
         # Compile changelog table
-        changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user).prefetch_related(
+        changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
+            user=request.user
+        ).prefetch_related(
             'changed_object_type'
             'changed_object_type'
         )[:20]
         )[:20]
         changelog_table = ObjectChangeTable(changelog)
         changelog_table = ObjectChangeTable(changelog)

+ 3 - 0
netbox/utilities/templates/builtins/copy_content.html

@@ -0,0 +1,3 @@
+<a class="btn btn-sm {{ color }} copy-content" data-clipboard-target="{{ target }}" title="Copy to clipboard">
+  <i class="mdi mdi-content-copy"></i>
+</a>

+ 15 - 2
netbox/utilities/templatetags/builtins/tags.py

@@ -1,9 +1,12 @@
 from django import template
 from django import template
 from django.http import QueryDict
 from django.http import QueryDict
 
 
+from utilities.utils import dict_to_querydict
+
 __all__ = (
 __all__ = (
     'badge',
     'badge',
     'checkmark',
     'checkmark',
+    'copy_content',
     'customfield_value',
     'customfield_value',
     'tag',
     'tag',
 )
 )
@@ -77,6 +80,17 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
     }
     }
 
 
 
 
+@register.inclusion_tag('builtins/copy_content.html')
+def copy_content(target, prefix=None, color='primary'):
+    """
+    Display a copy button to copy the content of a field.
+    """
+    return {
+        'target': f'#{prefix or ""}{target}',
+        'color': f'btn-{color}'
+    }
+
+
 @register.inclusion_tag('builtins/htmx_table.html', takes_context=True)
 @register.inclusion_tag('builtins/htmx_table.html', takes_context=True)
 def htmx_table(context, viewname, return_url=None, **kwargs):
 def htmx_table(context, viewname, return_url=None, **kwargs):
     """
     """
@@ -87,8 +101,7 @@ def htmx_table(context, viewname, return_url=None, **kwargs):
         viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`)
         viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`)
         return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used.
         return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used.
     """
     """
-    url_params = QueryDict(mutable=True)
-    url_params.update(kwargs)
+    url_params = dict_to_querydict(kwargs)
     url_params['return_url'] = return_url or context['request'].path
     url_params['return_url'] = return_url or context['request'].path
     return {
     return {
         'viewname': viewname,
         'viewname': viewname,

+ 15 - 1
netbox/utilities/utils.py

@@ -11,8 +11,9 @@ from django.core import serializers
 from django.db.models import Count, OuterRef, Subquery
 from django.db.models import Count, OuterRef, Subquery
 from django.db.models.functions import Coalesce
 from django.db.models.functions import Coalesce
 from django.http import QueryDict
 from django.http import QueryDict
-from django.utils.html import escape
 from django.utils import timezone
 from django.utils import timezone
+from django.utils.datastructures import MultiValueDict
+from django.utils.html import escape
 from django.utils.timezone import localtime
 from django.utils.timezone import localtime
 from jinja2.sandbox import SandboxedEnvironment
 from jinja2.sandbox import SandboxedEnvironment
 from mptt.models import MPTTModel
 from mptt.models import MPTTModel
@@ -231,6 +232,19 @@ def dict_to_filter_params(d, prefix=''):
     return params
     return params
 
 
 
 
+def dict_to_querydict(d, mutable=True):
+    """
+    Create a QueryDict instance from a regular Python dictionary.
+    """
+    qd = QueryDict(mutable=True)
+    for k, v in d.items():
+        item = MultiValueDict({k: v}) if isinstance(v, (list, tuple, set)) else {k: v}
+        qd.update(item)
+    if not mutable:
+        qd._mutable = False
+    return qd
+
+
 def normalize_querydict(querydict):
 def normalize_querydict(querydict):
     """
     """
     Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,
     Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,

+ 9 - 9
requirements.txt

@@ -1,7 +1,7 @@
 bleach==6.0.0
 bleach==6.0.0
-boto3==1.26.156
+boto3==1.28.1
 Django==4.2.2
 Django==4.2.2
-django-cors-headers==4.1.0
+django-cors-headers==4.2.0
 django-debug-toolbar==4.1.0
 django-debug-toolbar==4.1.0
 django-filter==23.2
 django-filter==23.2
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
@@ -9,27 +9,27 @@ django-mptt==0.14
 django-pglocks==1.0.4
 django-pglocks==1.0.4
 django-prometheus==2.3.1
 django-prometheus==2.3.1
 django-redis==5.3.0
 django-redis==5.3.0
-django-rich==1.6.0
+django-rich==1.7.0
 django-rq==2.8.1
 django-rq==2.8.1
-django-tables2==2.5.3
+django-tables2==2.6.0
 django-taggit==4.0.0
 django-taggit==4.0.0
 django-timezone-field==5.1
 django-timezone-field==5.1
 djangorestframework==3.14.0
 djangorestframework==3.14.0
-drf-spectacular==0.26.2
-drf-spectacular-sidecar==2023.6.1
+drf-spectacular==0.26.3
+drf-spectacular-sidecar==2023.7.1
 dulwich==0.21.5
 dulwich==0.21.5
 feedparser==6.0.10
 feedparser==6.0.10
 graphene-django==3.0.0
 graphene-django==3.0.0
 gunicorn==20.1.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.3.7
 Markdown==3.3.7
-mkdocs-material==9.1.16
+mkdocs-material==9.1.18
 mkdocstrings[python-legacy]==0.22.0
 mkdocstrings[python-legacy]==0.22.0
 netaddr==0.8.0
 netaddr==0.8.0
-Pillow==9.5.0
+Pillow==10.0.0
 psycopg[binary,pool]==3.1.9
 psycopg[binary,pool]==3.1.9
 PyYAML==6.0
 PyYAML==6.0
-sentry-sdk==1.25.1
+sentry-sdk==1.28.0
 social-auth-app-django==5.2.0
 social-auth-app-django==5.2.0
 social-auth-core[openidconnect]==4.4.2
 social-auth-core[openidconnect]==4.4.2
 svgwrite==1.4.3
 svgwrite==1.4.3

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