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

Merge pull request #13111 from netbox-community/develop

Release v3.5.5
Jeremy Stretch 2 лет назад
Родитель
Сommit
da239aea13
55 измененных файлов с 424 добавлено и 186 удалено
  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. 36 0
      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. 2 2
      netbox/extras/models/change_logging.py
  16. 4 5
      netbox/extras/models/models.py
  17. 5 2
      netbox/extras/models/reports.py
  18. 13 0
      netbox/extras/querysets.py
  19. 2 1
      netbox/extras/tests/test_api.py
  20. 4 4
      netbox/extras/views.py
  21. 2 1
      netbox/ipam/api/serializers.py
  22. 3 3
      netbox/ipam/api/views.py
  23. 7 0
      netbox/ipam/filtersets.py
  24. 2 1
      netbox/ipam/forms/model_forms.py
  25. 3 0
      netbox/ipam/models/asns.py
  26. 3 1
      netbox/ipam/models/vlans.py
  27. 38 1
      netbox/ipam/querysets.py
  28. 4 5
      netbox/ipam/tables/asn.py
  29. 28 3
      netbox/ipam/tables/ip.py
  30. 6 2
      netbox/ipam/tables/vlans.py
  31. 9 18
      netbox/ipam/views.py
  32. 1 1
      netbox/netbox/api/authentication.py
  33. 4 1
      netbox/netbox/middleware.py
  34. 1 1
      netbox/netbox/settings.py
  35. 1 1
      netbox/netbox/views/generic/bulk_views.py
  36. 0 0
      netbox/project-static/dist/netbox.js
  37. 0 0
      netbox/project-static/dist/netbox.js.map
  38. 1 1
      netbox/project-static/src/clipboard.ts
  39. 2 6
      netbox/templates/core/datafile.html
  40. 4 2
      netbox/templates/dcim/device.html
  41. 4 5
      netbox/templates/dcim/inc/cable_termination.html
  42. 12 2
      netbox/templates/dcim/virtualdevicecontext.html
  43. 66 60
      netbox/templates/extras/report_list.html
  44. 4 0
      netbox/templates/ipam/vlangroup.html
  45. 5 3
      netbox/templates/tenancy/object_contacts.html
  46. 2 4
      netbox/templates/users/api_token.html
  47. 4 2
      netbox/templates/virtualization/virtualmachine.html
  48. 5 0
      netbox/tenancy/models/contacts.py
  49. 32 2
      netbox/tenancy/tables/contacts.py
  50. 1 3
      netbox/users/tables.py
  51. 3 1
      netbox/users/views.py
  52. 3 0
      netbox/utilities/templates/builtins/copy_content.html
  53. 15 2
      netbox/utilities/templatetags/builtins/tags.py
  54. 15 1
      netbox/utilities/utils.py
  55. 8 8
      requirements.txt

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

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

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

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

+ 8 - 6
README.md

@@ -52,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations.
 ## Project Stats
 
 <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>
 </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)
   &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)
-  <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;
+  [![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)
+  &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>
 

+ 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`.
 
+!!! 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
 
 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:

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

@@ -1,5 +1,41 @@
 # NetBox v3.5
 
+## 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
+
+---
+
 ## v3.5.4 (2023-06-20)
 
 ### Enhancements

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

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

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

@@ -698,7 +698,8 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
             '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))
@@ -707,7 +708,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
 
 
 class VirtualDeviceContextSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
     device = NestedDeviceSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
     primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
@@ -880,12 +881,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
     parent = NestedInterfaceSerializer(required=False, allow_null=True)
     bridge = 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)
-    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)
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
@@ -907,9 +908,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
     mac_address = serializers.CharField(
         required=False,
         default=None,
+        allow_blank=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:
         model = Interface

+ 2 - 0
netbox/dcim/choices.py

@@ -810,6 +810,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_100GE_CXP = '100gbase-x-cxp'
     TYPE_100GE_CPAK = '100gbase-x-cpak'
     TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
+    TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
     TYPE_200GE_CFP2 = '200gbase-x-cfp2'
     TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
     TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
@@ -959,6 +960,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_100GE_CXP, 'CXP (100GE)'),
                 (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
                 (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
+                (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
                 (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
                 (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
                 (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):
         if not value.strip():
             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):
         params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)

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

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

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

@@ -306,7 +306,7 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
         model = DeviceType
         fields = [
             '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:
         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):

+ 13 - 0
netbox/dcim/views.py

@@ -3131,6 +3131,19 @@ class CableEditView(generic.ObjectEditView):
 
         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')
 class CableDeleteView(generic.ObjectDeleteView):

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

@@ -368,7 +368,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     Retrieve a list of recent changes.
     """
     metadata_class = ContentTypeMetadata
-    queryset = ObjectChange.objects.prefetch_related('user')
+    queryset = ObjectChange.objects.valid_models().prefetch_related('user')
     serializer_class = serializers.ObjectChangeSerializer
     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.core.cache import cache
 from django.db.models import Q
-from django.http import QueryDict
 from django.template.loader import render_to_string
 from django.urls import NoReverseMatch, resolve, reverse
 from django.utils.translation import gettext as _
@@ -19,7 +18,7 @@ from extras.utils import FeatureQuery
 from utilities.forms import BootstrapMixin
 from utilities.permissions import get_permission_for_model
 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
 
 __all__ = (
@@ -170,8 +169,7 @@ class ObjectCountsWidget(DashboardWidget):
                 qs = model.objects.restrict(request.user, 'view')
                 # Apply any specified 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)
                     qs = filterset(params, qs).qs
                     url = f'{url}?{params.urlencode()}'

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

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

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

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

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

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

+ 13 - 0
netbox/extras/querysets.py

@@ -1,3 +1,5 @@
+from django.apps import apps
+from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.aggregates import JSONBAgg
 from django.db.models import OuterRef, Subquery, Q
 
@@ -151,3 +153,14 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         )
 
         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.
+        content_type_ids = set(
+            ct.pk for ct in ContentType.objects.get_for_models(*apps.get_models()).values()
+        )
+        return self.filter(changed_object_type_id__in=content_type_ids)

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

@@ -8,7 +8,6 @@ from rest_framework import status
 
 from core.choices import ManagedFileRootPathChoices
 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.reports import Report
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
@@ -579,6 +578,7 @@ class ReportTest(APITestCase):
         super().setUp()
 
         # 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
 
     def test_get_report(self):
@@ -621,6 +621,7 @@ class ScriptTest(APITestCase):
         super().setUp()
 
         # 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
 
     def test_get_script(self):

+ 4 - 4
netbox/extras/views.py

@@ -511,7 +511,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
 #
 
 class ObjectChangeListView(generic.ObjectListView):
-    queryset = ObjectChange.objects.all()
+    queryset = ObjectChange.objects.valid_models()
     filterset = filtersets.ObjectChangeFilterSet
     filterset_form = forms.ObjectChangeFilterForm
     table = tables.ObjectChangeTable
@@ -521,10 +521,10 @@ class ObjectChangeListView(generic.ObjectListView):
 
 @register_model_view(ObjectChange)
 class ObjectChangeView(generic.ObjectView):
-    queryset = ObjectChange.objects.all()
+    queryset = ObjectChange.objects.valid_models()
 
     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
         ).exclude(
             pk=instance.pk
@@ -534,7 +534,7 @@ class ObjectChangeView(generic.ObjectView):
             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_id=instance.changed_object_id,
         )

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

@@ -218,12 +218,13 @@ class VLANGroupSerializer(NetBoxModelSerializer):
     scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
     scope = serializers.SerializerMethodField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
+    utilization = serializers.CharField(read_only=True)
 
     class Meta:
         model = VLANGroup
         fields = [
             '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 = []
 

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

@@ -1,5 +1,7 @@
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 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_pglocks import advisory_lock
 from drf_spectacular.utils import extend_schema
@@ -145,9 +147,7 @@ class FHRPGroupAssignmentViewSet(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
     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.db.models import Q
 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 dcim.models import Device, Interface, Region, Site, SiteGroup
@@ -414,6 +416,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         except (AddrFormatError, ValueError):
             return queryset.none()
 
+    @extend_schema_field(OpenApiTypes.STR)
     def filter_present_in_vrf(self, queryset, name, vrf):
         if vrf is None:
             return queryset.none
@@ -659,6 +662,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
             return queryset
         return queryset.filter(address__net_mask_length=value)
 
+    @extend_schema_field(OpenApiTypes.STR)
     def filter_present_in_vrf(self, queryset, name, vrf):
         if vrf is None:
             return queryset.none
@@ -727,6 +731,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
             Q(name__icontains=value)
         )
 
+    @extend_schema_field(OpenApiTypes.STR)
     def filter_related_ip(self, queryset, name, value):
         """
         Filter by VRF & prefix of assigned IP addresses.
@@ -941,9 +946,11 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
             pass
         return queryset.filter(qs_filter)
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_for_device(self, queryset, name, value):
         return queryset.get_for_device(value)
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_for_virtualmachine(self, queryset, name, 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:
             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(
                     "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
         if type(interface) in (Interface, VMInterface):
             parent = interface.parent_object
+            parent.snapshot()
             if self.cleaned_data['primary_for_parent']:
                 if ipaddress.address.version == 4:
                     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 ipam.fields import ASNField
+from ipam.querysets import ASNRangeQuerySet
 from netbox.models import OrganizationalModel, PrimaryModel
 
 __all__ = (
@@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel):
         null=True
     )
 
+    objects = ASNRangeQuerySet.as_manager()
+
     class Meta:
         ordering = ('name',)
         verbose_name = 'ASN range'

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

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

+ 38 - 1
netbox/ipam/querysets.py

@@ -1,8 +1,34 @@
 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.functions import Round
 
 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):
@@ -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):
 
     def get_for_device(self, device):

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

@@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
     tags = columns.TagColumn(
         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):
@@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name=_('Provider Count')
     )
     sites = columns.ManyToManyColumn(
-        linkify_item=True
+        linkify_item=True,
+        verbose_name=_('Sites')
     )
     comments = columns.MarkdownColumn()
     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>')
 
+AGGREGATE_COPY_BUTTON = """
+{% copy_content record.pk prefix="aggregate_" %}
+"""
+
 PREFIX_LINK = """
 {% 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 %}
   <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 %}
 """
 
+PREFIX_COPY_BUTTON = """
+{% copy_content record.pk prefix="prefix_" %}
+"""
+
 PREFIX_LINK_WITH_DEPTH = """
 {% load helpers %}
 {% if record.depth %}
@@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """
 
 IPADDRESS_LINK = """
 {% 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 %}
     <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 %}
@@ -48,6 +56,10 @@ IPADDRESS_LINK = """
 {% endif %}
 """
 
+IPADDRESS_COPY_BUTTON = """
+{% copy_content record.pk prefix="ipaddress_" %}
+"""
+
 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>
 """
@@ -99,7 +111,11 @@ class RIRTable(NetBoxTable):
 class AggregateTable(TenancyColumnsMixin, NetBoxTable):
     prefix = tables.Column(
         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(
         format="Y-m-d",
@@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable):
     tags = columns.TagColumn(
         url_name='ipam:aggregate_list'
     )
+    actions = columns.ActionsColumn(
+        extra_buttons=AGGREGATE_COPY_BUTTON
+    )
 
     class Meta(NetBoxTable.Meta):
         model = Aggregate
@@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
     tags = columns.TagColumn(
         url_name='ipam:prefix_list'
     )
+    actions = columns.ActionsColumn(
+        extra_buttons=PREFIX_COPY_BUTTON
+    )
 
     class Meta(NetBoxTable.Meta):
         model = Prefix
@@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
     tags = columns.TagColumn(
         url_name='ipam:ipaddress_list'
     )
+    actions = columns.ActionsColumn(
+        extra_buttons=IPADDRESS_COPY_BUTTON
+    )
 
     class Meta(NetBoxTable.Meta):
         model = IPAddress

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

@@ -70,6 +70,10 @@ class VLANGroupTable(NetBoxTable):
         url_params={'group_id': 'pk'},
         verbose_name='VLANs'
     )
+    utilization = columns.UtilizationColumn(
+        orderable=False,
+        verbose_name='Utilization'
+    )
     tags = columns.TagColumn(
         url_name='ipam:vlangroup_list'
     )
@@ -81,9 +85,9 @@ class VLANGroupTable(NetBoxTable):
         model = VLANGroup
         fields = (
             '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.db.models import Prefetch
+from django.db.models import F, Prefetch
 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.urls import reverse
 from django.utils.translation import gettext as _
@@ -198,7 +199,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
 #
 
 class ASNRangeListView(generic.ObjectListView):
-    queryset = ASNRange.objects.all()
+    queryset = ASNRange.objects.annotate_asn_counts()
     filterset = filtersets.ASNRangeFilterSet
     filterset_form = forms.ASNRangeFilterForm
     table = tables.ASNRangeTable
@@ -247,18 +248,14 @@ class ASNRangeBulkImportView(generic.BulkImportView):
 
 
 class ASNRangeBulkEditView(generic.BulkEditView):
-    queryset = ASNRange.objects.annotate(
-        site_count=count_related(Site, 'asns')
-    )
+    queryset = ASNRange.objects.annotate_asn_counts()
     filterset = filtersets.ASNRangeFilterSet
     table = tables.ASNRangeTable
     form = forms.ASNRangeBulkEditForm
 
 
 class ASNRangeBulkDeleteView(generic.BulkDeleteView):
-    queryset = ASNRange.objects.annotate(
-        site_count=count_related(Site, 'asns')
-    )
+    queryset = ASNRange.objects.annotate_asn_counts()
     filterset = filtersets.ASNRangeFilterSet
     table = tables.ASNRangeTable
 
@@ -886,9 +883,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
 #
 
 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_form = forms.VLANGroupFilterForm
     table = tables.VLANGroupTable
@@ -896,7 +891,7 @@ class VLANGroupListView(generic.ObjectListView):
 
 @register_model_view(VLANGroup)
 class VLANGroupView(generic.ObjectView):
-    queryset = VLANGroup.objects.all()
+    queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
 
     def get_extra_context(self, request, instance):
         related_models = (
@@ -938,18 +933,14 @@ class VLANGroupBulkImportView(generic.BulkImportView):
 
 
 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
     table = tables.VLANGroupTable
     form = forms.VLANGroupBulkEditForm
 
 
 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
     table = tables.VLANGroupTable
 

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

@@ -60,7 +60,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
 
         user = token.user
         # 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
             ldap_backend = LDAPBackend()
 

+ 4 - 1
netbox/netbox/middleware.py

@@ -49,6 +49,9 @@ class CoreMiddleware:
         # Attach the unique request ID as an HTTP header.
         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 is_api_request(request):
             response['API-Version'] = settings.REST_FRAMEWORK_VERSION
@@ -203,7 +206,7 @@ class MaintenanceModeMiddleware:
         """
         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 ' \
                             '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
 #
 
-VERSION = '3.5.4'
+VERSION = '3.5.5'
 
 # Hostname
 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():
                 if name in form.nullable_fields and name in nullified_fields:
                     getattr(obj, name).clear()
-                else:
+                elif form.cleaned_data[name]:
                     getattr(obj, name).set(form.cleaned_data[name])
 
             # 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';
 
 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);
   }
 }

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

@@ -39,9 +39,7 @@
               <th scope="row">Path</th>
               <td>
                 <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>
             </tr>
             <tr>
@@ -56,9 +54,7 @@
               <th scope="row">SHA256 Hash</th>
               <td>
               <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>
             </tr>
           </table>

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

@@ -194,12 +194,13 @@
                             <th scope="row">Primary IPv4</th>
                             <td>
                               {% 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 %}
                                   (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 %}
                                   (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 %}
+                                {% copy_content "primary_ip4" %}
                               {% else %}
                                 {{ ''|placeholder }}
                               {% endif %}
@@ -209,12 +210,13 @@
                             <th scope="row">Primary IPv6</th>
                             <td>
                               {% 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 %}
                                   (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 %}
                                   (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 %}
+                                {% copy_content "primary_ip6" %}
                               {% else %}
                                 {{ ''|placeholder }}
                               {% endif %}

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

@@ -15,15 +15,14 @@
         <td>Rack</td>
         <td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
       </tr>
-      <tr>
-        <td>Device</td>
-        <td>{{ terminations.0.device|linkify }}</td>
-      </tr>
       <tr>
         <td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
         <td>
           {% 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 %}
         </td>
       </tr>

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

@@ -31,13 +31,23 @@
           <tr>
             <th scope="row">Primary IPv4</th>
             <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>
           </tr>
           <tr>
             <th scope="row">Primary IPv6</th>
             <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>
           </tr>
           <tr>

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

@@ -38,71 +38,77 @@
         </h5>
         <div class="card-body">
           {% 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.name %}
+                      <tr>
                         <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>
-                      {% 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 %}
-                      </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>
                       </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>
     {% empty %}

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

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

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

@@ -2,10 +2,12 @@
 {% load helpers %}
 
 {% 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
-    </a>
+      </a>
+    {% endwith %}
   {% endif %}
 {% endblock %}
 

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

@@ -8,7 +8,7 @@
     <div class="col col-md-12">
       {% if not settings.ALLOW_TOKEN_RETRIEVAL %}
         <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>
       {% endif %}
       <div class="card">
@@ -19,9 +19,7 @@
               <th scope="row">Key</th>
               <td>
                 <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 id="token_id">{{ key }}</div>
               </td>

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

@@ -46,12 +46,13 @@
                         <th scope="row">Primary IPv4</th>
                         <td>
                           {% 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 %}
                               (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 %}
                               (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 %}
+                            {% copy_content "primary_ip4" %}
                           {% else %}
                             {{ ''|placeholder }}
                           {% endif %}
@@ -61,12 +62,13 @@
                         <th scope="row">Primary IPv6</th>
                         <td>
                           {% 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 %}
                               (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 %}
                               (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 %}
+                            {% copy_content "primary_ip6" %}
                           {% else %}
                             {{ ''|placeholder }}
                           {% endif %}

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

@@ -136,3 +136,8 @@ class ContactAssignment(ChangeLoggedModel):
 
     def get_absolute_url(self):
         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
+from django_tables2.utils import Accessor
 
 from netbox.tables import NetBoxTable, columns
 from tenancy.models import *
@@ -90,11 +91,40 @@ class ContactAssignmentTable(NetBoxTable):
     role = tables.Column(
         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=('edit', 'delete')
     )
 
     class Meta(NetBoxTable.Meta):
         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 = """
 {% 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 %}
 """
 

+ 3 - 1
netbox/users/views.py

@@ -159,7 +159,9 @@ class ProfileView(LoginRequiredMixin, View):
     def get(self, request):
 
         # 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'
         )[:20]
         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.http import QueryDict
 
+from utilities.utils import dict_to_querydict
+
 __all__ = (
     'badge',
     'checkmark',
+    'copy_content',
     'customfield_value',
     '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)
 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`)
         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
     return {
         '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.functions import Coalesce
 from django.http import QueryDict
-from django.utils.html import escape
 from django.utils import timezone
+from django.utils.datastructures import MultiValueDict
+from django.utils.html import escape
 from django.utils.timezone import localtime
 from jinja2.sandbox import SandboxedEnvironment
 from mptt.models import MPTTModel
@@ -231,6 +232,19 @@ def dict_to_filter_params(d, prefix=''):
     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):
     """
     Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,

+ 8 - 8
requirements.txt

@@ -1,6 +1,6 @@
 bleach==6.0.0
-boto3==1.26.156
-Django==4.1.9
+boto3==1.27.1
+Django==4.1.10
 django-cors-headers==4.1.0
 django-debug-toolbar==4.1.0
 django-filter==23.2
@@ -11,25 +11,25 @@ django-prometheus==2.3.1
 django-redis==5.3.0
 django-rich==1.6.0
 django-rq==2.8.1
-django-tables2==2.5.3
+django-tables2==2.6.0
 django-taggit==4.0.0
 django-timezone-field==5.1
 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
 feedparser==6.0.10
 graphene-django==3.0.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Markdown==3.3.7
-mkdocs-material==9.1.16
+mkdocs-material==9.1.18
 mkdocstrings[python-legacy]==0.22.0
 netaddr==0.8.0
-Pillow==9.5.0
+Pillow==10.0.0
 psycopg2-binary==2.9.6
 PyYAML==6.0
-sentry-sdk==1.25.1
+sentry-sdk==1.27.1
 social-auth-app-django==5.2.0
 social-auth-core[openidconnect]==4.4.2
 svgwrite==1.4.3

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