فهرست منبع

Merge pull request #14057 from netbox-community/develop

Release v3.6.4
Jeremy Stretch 2 سال پیش
والد
کامیت
d195f9c6ea
44فایلهای تغییر یافته به همراه280 افزوده شده و 87 حذف شده
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 4 4
      .github/workflows/ci.yml
  4. 1 1
      .github/workflows/lock.yml
  5. 1 1
      .github/workflows/stale.yml
  6. 6 1
      base_requirements.txt
  7. 10 2
      docs/configuration/miscellaneous.md
  8. 1 1
      docs/customization/custom-scripts.md
  9. 1 1
      docs/customization/reports.md
  10. 32 0
      docs/release-notes/version-3.6.md
  11. 6 6
      netbox/dcim/api/views.py
  12. 17 0
      netbox/dcim/filtersets.py
  13. 1 1
      netbox/dcim/forms/bulk_import.py
  14. 19 12
      netbox/dcim/forms/filtersets.py
  15. 2 0
      netbox/dcim/svg/cables.py
  16. 10 0
      netbox/dcim/tests/test_filtersets.py
  17. 10 8
      netbox/dcim/views.py
  18. 5 0
      netbox/extras/models/customfields.py
  19. 1 1
      netbox/extras/reports.py
  20. 16 8
      netbox/extras/views.py
  21. 3 2
      netbox/ipam/api/views.py
  22. 5 1
      netbox/ipam/forms/filtersets.py
  23. 21 0
      netbox/netbox/api/viewsets/__init__.py
  24. 11 0
      netbox/netbox/constants.py
  25. 7 1
      netbox/netbox/models/__init__.py
  26. 6 1
      netbox/netbox/settings.py
  27. 3 1
      netbox/netbox/tables/columns.py
  28. 0 0
      netbox/project-static/dist/netbox.js
  29. 0 0
      netbox/project-static/dist/netbox.js.map
  30. 1 1
      netbox/project-static/src/clipboard.ts
  31. 0 5
      netbox/templates/account/token.html
  32. 1 1
      netbox/templates/dcim/platform.html
  33. 6 0
      netbox/templates/django/forms/widgets/checkbox.html
  34. 2 0
      netbox/templates/generic/bulk_import.html
  35. 3 3
      netbox/tenancy/api/views.py
  36. 3 0
      netbox/users/forms/model_forms.py
  37. 1 1
      netbox/users/tables.py
  38. 4 2
      netbox/utilities/counters.py
  39. 8 0
      netbox/utilities/templates/form_helpers/render_field.html
  40. 6 0
      netbox/utilities/templatetags/builtins/tags.py
  41. 28 3
      netbox/utilities/tests/test_counters.py
  42. 2 2
      netbox/wireless/api/views.py
  43. 0 1
      netbox/wireless/models.py
  44. 14 13
      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.6.3
+      placeholder: v3.6.4
     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.6.3
+      placeholder: v3.6.4
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 4 - 4
.github/workflows/ci.yml

@@ -31,15 +31,15 @@ jobs:
 
 
     steps:
     steps:
     - name: Check out repo
     - name: Check out repo
-      uses: actions/checkout@v2
+      uses: actions/checkout@v4
 
 
     - name: Set up Python ${{ matrix.python-version }}
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
+      uses: actions/setup-python@v4
       with:
       with:
         python-version: ${{ matrix.python-version }}
         python-version: ${{ matrix.python-version }}
 
 
     - name: Use Node.js ${{ matrix.node-version }}
     - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v2
+      uses: actions/setup-node@v3
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
     
     
@@ -47,7 +47,7 @@ jobs:
       run: npm install -g yarn
       run: npm install -g yarn
     
     
     - name: Setup Node.js with Yarn Caching
     - name: Setup Node.js with Yarn Caching
-      uses: actions/setup-node@v2
+      uses: actions/setup-node@v3
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
         cache: yarn
         cache: yarn

+ 1 - 1
.github/workflows/lock.yml

@@ -14,7 +14,7 @@ jobs:
   lock:
   lock:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-      - uses: dessant/lock-threads@v3
+      - uses: dessant/lock-threads@v4
         with:
         with:
           issue-inactive-days: 90
           issue-inactive-days: 90
           pr-inactive-days: 30
           pr-inactive-days: 30

+ 1 - 1
.github/workflows/stale.yml

@@ -15,7 +15,7 @@ jobs:
 
 
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-      - uses: actions/stale@v6
+      - uses: actions/stale@v8
         with:
         with:
           close-issue-message: >
           close-issue-message: >
             This issue has been automatically closed due to lack of activity. In an
             This issue has been automatically closed due to lack of activity. In an

+ 6 - 1
base_requirements.txt

@@ -23,8 +23,9 @@ django-filter
 django-graphiql-debug-toolbar
 django-graphiql-debug-toolbar
 
 
 # Modified Preorder Tree Traversal (recursive nesting of objects)
 # Modified Preorder Tree Traversal (recursive nesting of objects)
+# Pinned to 0.14.0; 0.15.0 requires Python 3.9+
 # https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
 # https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
-django-mptt
+django-mptt==0.14.0
 
 
 # Context managers for PostgreSQL advisory locks
 # Context managers for PostgreSQL advisory locks
 # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
 # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
@@ -120,6 +121,10 @@ psycopg[binary,pool]
 # https://github.com/yaml/pyyaml/blob/master/CHANGES
 # https://github.com/yaml/pyyaml/blob/master/CHANGES
 PyYAML
 PyYAML
 
 
+# Requests
+# https://github.com/psf/requests/blob/main/HISTORY.md
+requests
+
 # Sentry SDK
 # Sentry SDK
 # https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
 # https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
 sentry-sdk
 sentry-sdk

+ 10 - 2
docs/configuration/miscellaneous.md

@@ -80,6 +80,14 @@ changes in the database indefinitely.
 
 
 ---
 ---
 
 
+## DATA_UPLOAD_MAX_MEMORY_SIZE
+
+Default: `2621440` (2.5 MB)
+
+The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` data). Requests which exceed this size will raise a `RequestDataTooBig` exception.
+
+---
+
 ## ENFORCE_GLOBAL_UNIQUE
 ## ENFORCE_GLOBAL_UNIQUE
 
 
 !!! tip "Dynamic Configuration Parameter"
 !!! tip "Dynamic Configuration Parameter"
@@ -90,9 +98,9 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
 
 
 ---
 ---
 
 
-## `FILE_UPLOAD_MAX_MEMORY_SIZE`
+## FILE_UPLOAD_MAX_MEMORY_SIZE
 
 
-Default: `2621440` (2.5 MB).
+Default: `2621440` (2.5 MB)
 
 
 The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
 The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
 
 

+ 1 - 1
docs/customization/custom-scripts.md

@@ -288,7 +288,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
 ## Running Custom Scripts
 ## Running Custom Scripts
 
 
 !!! note
 !!! note
-    To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
+    To run a custom script, a user must be assigned via permissions for `Extras > Script`, `Extras > ScriptModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
 
 
     ![Adding the run action to a permission](../media/admin_ui_run_permission.png)
     ![Adding the run action to a permission](../media/admin_ui_run_permission.png)
 
 

+ 1 - 1
docs/customization/reports.md

@@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
 ## Running Reports
 ## Running Reports
 
 
 !!! note
 !!! note
-    To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
+    To run a report, a user must be assigned via permissions for `Extras > Report`, `Extras > ReportModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
 
 
     ![Adding the run action to a permission](../media/admin_ui_run_permission.png)
     ![Adding the run action to a permission](../media/admin_ui_run_permission.png)
 
 

+ 32 - 0
docs/release-notes/version-3.6.md

@@ -1,5 +1,37 @@
 # NetBox v3.6
 # NetBox v3.6
 
 
+## v3.6.4 (2023-10-17)
+
+### Enhancements
+
+* [#12831](https://github.com/netbox-community/netbox/issues/12831) - Include circuit description in cable trace SVG image
+* [#12872](https://github.com/netbox-community/netbox/issues/12872) - Introduce the `DATA_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
+* [#13950](https://github.com/netbox-community/netbox/issues/13950) - Display custom choice field labels rather than values in UI
+* [#13957](https://github.com/netbox-community/netbox/issues/13957) - Add DNS name filter on IP addresses list
+* [#13962](https://github.com/netbox-community/netbox/issues/13962) - Add a copy-to-clipboard button for API tokens
+* [#13972](https://github.com/netbox-community/netbox/issues/13972) - Introduce a filter to find unterminated cables
+
+### Bug Fixes
+
+* [#11987](https://github.com/netbox-community/netbox/issues/11987) - Fix validation of bulk cable updates via bulk import form
+* [#12328](https://github.com/netbox-community/netbox/issues/12328) - Ensure generic foreign key relationships are populated in REST API serializations of objects
+* [#12336](https://github.com/netbox-community/netbox/issues/12336) - Employ PostgreSQL advisory locks to avoid duplicate MPTT tree IDs when bulk creating objects
+* [#13064](https://github.com/netbox-community/netbox/issues/13064) - Fix resetting of checkbox fields triggered by HTMX form re-rendering
+* [#13440](https://github.com/netbox-community/netbox/issues/13440) - Fix support for assigning a tenant when creating "next available" VLANs via the REST API
+* [#13746](https://github.com/netbox-community/netbox/issues/13746) - Fix support for setting custom field values when creating "next available" IP addresses via the REST API
+* [#13872](https://github.com/netbox-community/netbox/issues/13872) - Add CSV delimiter field to file upload tab under bulk object upload views
+* [#13876](https://github.com/netbox-community/netbox/issues/13876) - Fix support for assigning an interface when creating "next available" IP addresses via the REST API
+* [#13910](https://github.com/netbox-community/netbox/issues/13910) - Correct "add device" button link under platform view
+* [#13944](https://github.com/netbox-community/netbox/issues/13944) - Correct serialization of several report attributes in the REST API
+* [#13966](https://github.com/netbox-community/netbox/issues/13966) - Restore "last login" column on users table
+* [#14013](https://github.com/netbox-community/netbox/issues/14013) - Fix device role filter choices under inventory items list filters
+* [#14023](https://github.com/netbox-community/netbox/issues/14023) - Fix exception when bulk disconnecting interfaces connected to the same cable
+* [#14025](https://github.com/netbox-community/netbox/issues/14025) - Fix exception when viewing a script that begins with the same name as another
+* [#14026](https://github.com/netbox-community/netbox/issues/14026) - Optimize the automatic creation of available IP addresses for large prefixes
+* [#14042](https://github.com/netbox-community/netbox/issues/14042) - Fix duplicated child object count decrements when removing objects in bulk
+
+---
+
 ## v3.6.3 (2023-09-26)
 ## v3.6.3 (2023-09-26)
 
 
 ### Enhancements
 ### Enhancements

+ 6 - 6
netbox/dcim/api/views.py

@@ -20,7 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.renderers import TextRenderer
 from netbox.api.renderers import TextRenderer
-from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
@@ -98,7 +98,7 @@ class PassThroughPortMixin(object):
 # Regions
 # Regions
 #
 #
 
 
-class RegionViewSet(NetBoxModelViewSet):
+class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     queryset = Region.objects.add_related_count(
     queryset = Region.objects.add_related_count(
         Region.objects.all(),
         Region.objects.all(),
         Site,
         Site,
@@ -114,7 +114,7 @@ class RegionViewSet(NetBoxModelViewSet):
 # Site groups
 # Site groups
 #
 #
 
 
-class SiteGroupViewSet(NetBoxModelViewSet):
+class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     queryset = SiteGroup.objects.add_related_count(
     queryset = SiteGroup.objects.add_related_count(
         SiteGroup.objects.all(),
         SiteGroup.objects.all(),
         Site,
         Site,
@@ -149,7 +149,7 @@ class SiteViewSet(NetBoxModelViewSet):
 # Locations
 # Locations
 #
 #
 
 
-class LocationViewSet(NetBoxModelViewSet):
+class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     queryset = Location.objects.add_related_count(
     queryset = Location.objects.add_related_count(
         Location.objects.add_related_count(
         Location.objects.add_related_count(
             Location.objects.all(),
             Location.objects.all(),
@@ -350,7 +350,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.DeviceBayTemplateFilterSet
     filterset_class = filtersets.DeviceBayTemplateFilterSet
 
 
 
 
-class InventoryItemTemplateViewSet(NetBoxModelViewSet):
+class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
     queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
     serializer_class = serializers.InventoryItemTemplateSerializer
     serializer_class = serializers.InventoryItemTemplateSerializer
     filterset_class = filtersets.InventoryItemTemplateFilterSet
     filterset_class = filtersets.InventoryItemTemplateFilterSet
@@ -538,7 +538,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
-class InventoryItemViewSet(NetBoxModelViewSet):
+class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
     queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
     serializer_class = serializers.InventoryItemSerializer
     serializer_class = serializers.InventoryItemSerializer
     filterset_class = filtersets.InventoryItemFilterSet
     filterset_class = filtersets.InventoryItemFilterSet

+ 17 - 0
netbox/dcim/filtersets.py

@@ -1745,6 +1745,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         method='filter_by_cable_end_b',
         method='filter_by_cable_end_b',
         field_name='terminations__termination_id'
         field_name='terminations__termination_id'
     )
     )
+    unterminated = django_filters.BooleanFilter(
+        method='_unterminated',
+        label=_('Unterminated'),
+    )
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=CableTypeChoices
         choices=CableTypeChoices
     )
     )
@@ -1812,6 +1816,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         # Filter by termination id and cable_end type
         # Filter by termination id and cable_end type
         return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
         return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
 
 
+    def _unterminated(self, queryset, name, value):
+        if value:
+            terminated_ids = (
+                queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A)
+                .filter(terminations__cable_end=CableEndChoices.SIDE_B)
+                .values("id")
+            )
+            return queryset.exclude(id__in=terminated_ids)
+        else:
+            return queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A).filter(
+                terminations__cable_end=CableEndChoices.SIDE_B
+            )
+
 
 
 class CableTerminationFilterSet(BaseFilterSet):
 class CableTerminationFilterSet(BaseFilterSet):
     termination_type = ContentTypeFilter()
     termination_type = ContentTypeFilter()

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

@@ -1192,7 +1192,7 @@ class CableImportForm(NetBoxModelImportForm):
                 termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
                 termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
             else:
             else:
                 termination_object = model.objects.get(device=device, name=name)
                 termination_object = model.objects.get(device=device, name=name)
-            if termination_object.cable is not None:
+            if termination_object.cable is not None and termination_object.cable != self.instance:
                 raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
                 raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
             raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
             raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")

+ 19 - 12
netbox/dcim/forms/filtersets.py

@@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('Device type')
         label=_('Device type')
     )
     )
-    role_id = DynamicModelMultipleChoiceField(
+    device_role_id = DynamicModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         required=False,
         required=False,
         label=_('Device role')
         label=_('Device role')
@@ -910,7 +910,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
         (_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
-        (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')),
+        (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')),
         (_('Tenant'), ('tenant_group_id', 'tenant_id')),
         (_('Tenant'), ('tenant_group_id', 'tenant_id')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
@@ -979,6 +979,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         choices=add_blank_choice(CableLengthUnitChoices),
         choices=add_blank_choice(CableLengthUnitChoices),
         required=False
         required=False
     )
     )
+    unterminated = forms.NullBooleanField(
+        label=_('Unterminated'),
+        required=False,
+        widget=forms.Select(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
@@ -1136,7 +1143,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Attributes'), ('name', 'label', 'type', 'speed')),
         (_('Attributes'), ('name', 'label', 'type', 'speed')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
-        (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+        (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         (_('Connection'), ('cabled', 'connected', 'occupied')),
         (_('Connection'), ('cabled', 'connected', 'occupied')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1158,7 +1165,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Attributes'), ('name', 'label', 'type', 'speed')),
         (_('Attributes'), ('name', 'label', 'type', 'speed')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
-        (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+        (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         (_('Connection'), ('cabled', 'connected', 'occupied')),
         (_('Connection'), ('cabled', 'connected', 'occupied')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1180,7 +1187,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Attributes'), ('name', 'label', 'type')),
         (_('Attributes'), ('name', 'label', 'type')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
-        (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+        (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         (_('Connection'), ('cabled', 'connected', 'occupied')),
         (_('Connection'), ('cabled', 'connected', 'occupied')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1197,7 +1204,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Attributes'), ('name', 'label', 'type')),
         (_('Attributes'), ('name', 'label', 'type')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
-        (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+        (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         (_('Connection'), ('cabled', 'connected', 'occupied')),
         (_('Connection'), ('cabled', 'connected', 'occupied')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1217,7 +1224,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         (_('PoE'), ('poe_mode', 'poe_type')),
         (_('PoE'), ('poe_mode', 'poe_type')),
         (_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
         (_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
-        (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
+        (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
         (_('Connection'), ('cabled', 'connected', 'occupied')),
         (_('Connection'), ('cabled', 'connected', 'occupied')),
     )
     )
     vdc_id = DynamicModelMultipleChoiceField(
     vdc_id = DynamicModelMultipleChoiceField(
@@ -1324,7 +1331,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Attributes'), ('name', 'label', 'type', 'color')),
         (_('Attributes'), ('name', 'label', 'type', 'color')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
-        (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+        (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         (_('Cable'), ('cabled', 'occupied')),
         (_('Cable'), ('cabled', 'occupied')),
     )
     )
     model = FrontPort
     model = FrontPort
@@ -1346,7 +1353,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Attributes'), ('name', 'label', 'type', 'color')),
         (_('Attributes'), ('name', 'label', 'type', 'color')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
-        (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+        (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         (_('Cable'), ('cabled', 'occupied')),
         (_('Cable'), ('cabled', 'occupied')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1367,7 +1374,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Attributes'), ('name', 'label', 'position')),
         (_('Attributes'), ('name', 'label', 'position')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
-        (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+        (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
     position = forms.CharField(
     position = forms.CharField(
@@ -1382,7 +1389,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Attributes'), ('name', 'label')),
         (_('Attributes'), ('name', 'label')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
-        (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+        (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -1393,7 +1400,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
         (_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
-        (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+        (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
     )
     )
     role_id = DynamicModelMultipleChoiceField(
     role_id = DynamicModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),
         queryset=InventoryItemRole.objects.all(),

+ 2 - 0
netbox/dcim/svg/cables.py

@@ -160,6 +160,8 @@ class CableTraceSVG:
         elif instance._meta.model_name == 'circuit':
         elif instance._meta.model_name == 'circuit':
             labels[0] = f'Circuit {instance}'
             labels[0] = f'Circuit {instance}'
             labels.append(instance.provider)
             labels.append(instance.provider)
+            if instance.description:
+                labels.append(instance.description)
         elif instance._meta.model_name == 'circuittermination':
         elif instance._meta.model_name == 'circuittermination':
             if instance.xconnect_id:
             if instance.xconnect_id:
                 labels.append(f'{instance.xconnect_id}')
                 labels.append(f'{instance.xconnect_id}')

+ 10 - 0
netbox/dcim/tests/test_filtersets.py

@@ -4275,6 +4275,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
             Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
             Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[5], name='Interface 13', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
         )
         )
         Interface.objects.bulk_create(interfaces)
         Interface.objects.bulk_create(interfaces)
 
 
@@ -4290,6 +4291,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
         Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
         Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
         Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
 
 
+        # Cable for unterminated test
+        Cable(a_terminations=[interfaces[12]], label='Cable 8', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_DECOMMISSIONING).save()
+
     def test_label(self):
     def test_label(self):
         params = {'label': ['Cable 1', 'Cable 2']}
         params = {'label': ['Cable 1', 'Cable 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -4368,6 +4372,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         }
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
+    def test_unterminated(self):
+        params = {'unterminated': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'unterminated': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
+
 
 
 class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
 class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerPanel.objects.all()
     queryset = PowerPanel.objects.all()

+ 10 - 8
netbox/dcim/views.py

@@ -122,16 +122,18 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View)
             if form.is_valid():
             if form.is_valid():
 
 
                 with transaction.atomic():
                 with transaction.atomic():
-
                     count = 0
                     count = 0
+                    cable_ids = set()
                     for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
                     for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
-                        if obj.cable is None:
-                            continue
-                        obj.cable.delete()
-                        count += 1
-
-                messages.success(request, "Disconnected {} {}".format(
-                    count, self.queryset.model._meta.verbose_name_plural
+                        if obj.cable:
+                            cable_ids.add(obj.cable.pk)
+                            count += 1
+                    for cable in Cable.objects.filter(pk__in=cable_ids):
+                        cable.delete()
+
+                messages.success(request, _("Disconnected {count} {type}").format(
+                    count=count,
+                    type=self.queryset.model._meta.verbose_name_plural
                 ))
                 ))
 
 
                 return redirect(return_url)
                 return redirect(return_url)

+ 5 - 0
netbox/extras/models/customfields.py

@@ -232,6 +232,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
             return self.choice_set.choices
             return self.choice_set.choices
         return []
         return []
 
 
+    def get_choice_label(self, value):
+        if not hasattr(self, '_choice_map'):
+            self._choice_map = dict(self.choices)
+        return self._choice_map.get(value, value)
+
     def populate_initial_data(self, content_types):
     def populate_initial_data(self, content_types):
         """
         """
         Populate initial custom field data upon either a) the creation of a new CustomField, or
         Populate initial custom field data upon either a) the creation of a new CustomField, or

+ 1 - 1
netbox/extras/reports.py

@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
 
 
 def get_module_and_report(module_name, report_name):
 def get_module_and_report(module_name, report_name):
     module = ReportModule.objects.get(file_path=f'{module_name}.py')
     module = ReportModule.objects.get(file_path=f'{module_name}.py')
-    report = module.reports.get(report_name)
+    report = module.reports.get(report_name)()
     return module, report
     return module, report
 
 
 
 

+ 16 - 8
netbox/extras/views.py

@@ -978,6 +978,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
         })
         })
 
 
 
 
+def get_report_module(module, request):
+    return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
+
+
 class ReportView(ContentTypePermissionRequiredMixin, View):
 class ReportView(ContentTypePermissionRequiredMixin, View):
     """
     """
     Display a single Report and its associated Job (if any).
     Display a single Report and its associated Job (if any).
@@ -986,7 +990,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_report'
         return 'extras.view_report'
 
 
     def get(self, request, module, name):
     def get(self, request, module, name):
-        module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
+        module = get_report_module(module, request)
         report = module.reports[name]()
         report = module.reports[name]()
 
 
         object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
         object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
@@ -1007,7 +1011,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
         if not request.user.has_perm('extras.run_report'):
         if not request.user.has_perm('extras.run_report'):
             return HttpResponseForbidden()
             return HttpResponseForbidden()
 
 
-        module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
+        module = get_report_module(module, request)
         report = module.reports[name]()
         report = module.reports[name]()
         form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
         form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
 
 
@@ -1046,7 +1050,7 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_report'
         return 'extras.view_report'
 
 
     def get(self, request, module, name):
     def get(self, request, module, name):
-        module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
+        module = get_report_module(module, request)
         report = module.reports[name]()
         report = module.reports[name]()
 
 
         return render(request, 'extras/report/source.html', {
         return render(request, 'extras/report/source.html', {
@@ -1062,7 +1066,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_report'
         return 'extras.view_report'
 
 
     def get(self, request, module, name):
     def get(self, request, module, name):
-        module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
+        module = get_report_module(module, request)
         report = module.reports[name]()
         report = module.reports[name]()
 
 
         object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
         object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
@@ -1151,13 +1155,17 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         })
         })
 
 
 
 
+def get_script_module(module, request):
+    return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
+
+
 class ScriptView(ContentTypePermissionRequiredMixin, View):
 class ScriptView(ContentTypePermissionRequiredMixin, View):
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return 'extras.view_script'
         return 'extras.view_script'
 
 
     def get(self, request, module, name):
     def get(self, request, module, name):
-        module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
+        module = get_script_module(module, request)
         script = module.scripts[name]()
         script = module.scripts[name]()
         form = script.as_form(initial=normalize_querydict(request.GET))
         form = script.as_form(initial=normalize_querydict(request.GET))
 
 
@@ -1181,7 +1189,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
         if not request.user.has_perm('extras.run_script'):
         if not request.user.has_perm('extras.run_script'):
             return HttpResponseForbidden()
             return HttpResponseForbidden()
 
 
-        module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
+        module = get_script_module(module, request)
         script = module.scripts[name]()
         script = module.scripts[name]()
         form = script.as_form(request.POST, request.FILES)
         form = script.as_form(request.POST, request.FILES)
 
 
@@ -1218,7 +1226,7 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_script'
         return 'extras.view_script'
 
 
     def get(self, request, module, name):
     def get(self, request, module, name):
-        module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
+        module = get_script_module(module, request)
         script = module.scripts[name]()
         script = module.scripts[name]()
 
 
         return render(request, 'extras/script/source.html', {
         return render(request, 'extras/script/source.html', {
@@ -1234,7 +1242,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_script'
         return 'extras.view_script'
 
 
     def get(self, request, module, name):
     def get(self, request, module, name):
-        module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
+        module = get_script_module(module, request)
         script = module.scripts[name]()
         script = module.scripts[name]()
 
 
         object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
         object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')

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

@@ -266,6 +266,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
 
 
         # Normalize request data to a list of objects
         # Normalize request data to a list of objects
         requested_objects = request.data if isinstance(request.data, list) else [request.data]
         requested_objects = request.data if isinstance(request.data, list) else [request.data]
+        limit = len(requested_objects)
 
 
         # Serialize and validate the request data
         # Serialize and validate the request data
         serializer = self.write_serializer_class(data=requested_objects, many=True, context={
         serializer = self.write_serializer_class(data=requested_objects, many=True, context={
@@ -279,7 +280,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
             )
             )
 
 
         with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]):
         with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]):
-            available_objects = self.get_available_objects(parent)
+            available_objects = self.get_available_objects(parent, limit)
 
 
             # Determine if the requested number of objects is available
             # Determine if the requested number of objects is available
             if not self.check_sufficient_available(serializer.validated_data, available_objects):
             if not self.check_sufficient_available(serializer.validated_data, available_objects):
@@ -289,7 +290,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
                 )
                 )
 
 
             # Prepare object data for deserialization
             # Prepare object data for deserialization
-            requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent)
+            requested_objects = self.prep_object_data(requested_objects, available_objects, parent)
 
 
             # Initialize the serializer with a list or a single object depending on what was requested
             # Initialize the serializer with a list or a single object depending on what was requested
             serializer_class = get_serializer_for_model(self.queryset.model)
             serializer_class = get_serializer_for_model(self.queryset.model)

+ 5 - 1
netbox/ipam/forms/filtersets.py

@@ -295,7 +295,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = IPAddress
     model = IPAddress
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
-        (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
+        (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')),
         (_('VRF'), ('vrf_id', 'present_in_vrf_id')),
         (_('VRF'), ('vrf_id', 'present_in_vrf_id')),
         (_('Tenant'), ('tenant_group_id', 'tenant_id')),
         (_('Tenant'), ('tenant_group_id', 'tenant_id')),
         (_('Device/VM'), ('device_id', 'virtual_machine_id')),
         (_('Device/VM'), ('device_id', 'virtual_machine_id')),
@@ -357,6 +357,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
+    dns_name = forms.CharField(
+        required=False,
+        label=_('DNS Name')
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 

+ 21 - 0
netbox/netbox/api/viewsets/__init__.py

@@ -3,6 +3,8 @@ import logging
 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 ProtectedError
 from django.db.models import ProtectedError
+from django_pglocks import advisory_lock
+from netbox.constants import ADVISORY_LOCK_KEYS
 from rest_framework import mixins as drf_mixins
 from rest_framework import mixins as drf_mixins
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 from rest_framework.viewsets import GenericViewSet
@@ -157,3 +159,22 @@ class NetBoxModelViewSet(
         logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
         logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
 
 
         return super().perform_destroy(instance)
         return super().perform_destroy(instance)
+
+
+class MPTTLockedMixin:
+    """
+    Puts pglock on objects that derive from MPTTModel for parallel API calling.
+    Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS
+    """
+
+    def create(self, request, *args, **kwargs):
+        with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
+            return super().create(request, *args, **kwargs)
+
+    def update(self, request, *args, **kwargs):
+        with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
+            return super().update(request, *args, **kwargs)
+
+    def destroy(self, request, *args, **kwargs):
+        with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
+            return super().destroy(request, *args, **kwargs)

+ 11 - 0
netbox/netbox/constants.py

@@ -11,8 +11,19 @@ RQ_QUEUE_LOW = 'low'
 # When adding a new key, pick something arbitrary and unique so that it is easily searchable in
 # When adding a new key, pick something arbitrary and unique so that it is easily searchable in
 # query logs.
 # query logs.
 ADVISORY_LOCK_KEYS = {
 ADVISORY_LOCK_KEYS = {
+    # Available object locks
     'available-prefixes': 100100,
     'available-prefixes': 100100,
     'available-ips': 100200,
     'available-ips': 100200,
     'available-vlans': 100300,
     'available-vlans': 100300,
     'available-asns': 100400,
     'available-asns': 100400,
+
+    # MPTT locks
+    'region': 105100,
+    'sitegroup': 105200,
+    'location': 105300,
+    'tenantgroup': 105400,
+    'contactgroup': 105500,
+    'wirelesslangroup': 105600,
+    'inventoryitem': 105700,
+    'inventoryitemtemplate': 105800,
 }
 }

+ 7 - 1
netbox/netbox/models/__init__.py

@@ -1,5 +1,6 @@
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
+from django.core.exceptions import ObjectDoesNotExist
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
@@ -85,11 +86,16 @@ class NetBoxModel(NetBoxFeatureSet, models.Model):
 
 
                 if ct_value and fk_value:
                 if ct_value and fk_value:
                     klass = getattr(self, field.ct_field).model_class()
                     klass = getattr(self, field.ct_field).model_class()
-                    if not klass.objects.filter(pk=fk_value).exists():
+                    try:
+                        obj = klass.objects.get(pk=fk_value)
+                    except ObjectDoesNotExist:
                         raise ValidationError({
                         raise ValidationError({
                             field.fk_field: f"Related object not found using the provided value: {fk_value}."
                             field.fk_field: f"Related object not found using the provided value: {fk_value}."
                         })
                         })
 
 
+                    # update the GFK field value
+                    setattr(self, field.name, obj)
+
 
 
 #
 #
 # NetBox internal base models
 # NetBox internal base models

+ 6 - 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.6.3'
+VERSION = '3.6.4'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -95,6 +95,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
 CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
 CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
 CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False)
 CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False)
 CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
 CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
+DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440)
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
 DEBUG = getattr(configuration, 'DEBUG', False)
 DEBUG = getattr(configuration, 'DEBUG', False)
@@ -355,6 +356,7 @@ INSTALLED_APPS = [
     'django.contrib.messages',
     'django.contrib.messages',
     'django.contrib.staticfiles',
     'django.contrib.staticfiles',
     'django.contrib.humanize',
     'django.contrib.humanize',
+    'django.forms',
     'corsheaders',
     'corsheaders',
     'debug_toolbar',
     'debug_toolbar',
     'graphiql_debug_toolbar',
     'graphiql_debug_toolbar',
@@ -430,6 +432,9 @@ TEMPLATES = [
     },
     },
 ]
 ]
 
 
+# This allows us to override Django's stock form widget templates
+FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
+
 # Set up authentication backends
 # Set up authentication backends
 if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
 if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
     REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]
     REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]

+ 3 - 1
netbox/netbox/tables/columns.py

@@ -483,8 +483,10 @@ class CustomFieldColumn(tables.Column):
             return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
             return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
         if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
         if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
             return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
             return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
+        if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
+            return self.customfield.get_choice_label(value)
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
-            return ', '.join(v for v in value)
+            return ', '.join(self.customfield.get_choice_label(v) for v in value)
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
             return mark_safe(', '.join(
             return mark_safe(', '.join(
                 self._linkify_item(obj) for obj in self.customfield.deserialize(value)
                 self._linkify_item(obj) for obj in self.customfield.deserialize(value)

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 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-content')) {
+  for (const element of getElements('.copy-content')) {
     new Clipboard(element);
     new Clipboard(element);
   }
   }
 }
 }

+ 0 - 5
netbox/templates/account/token.html

@@ -15,11 +15,6 @@
 {% block content %}
 {% block content %}
   <div class="row">
   <div class="row">
     <div class="col col-md-12">
     <div class="col col-md-12">
-      {% if key and 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-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">
       <div class="card">
         <h5 class="card-header">{% trans "Token" %}</h5>
         <h5 class="card-header">{% trans "Token" %}</h5>
         <div class="card-body">
         <div class="card-body">

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

@@ -13,7 +13,7 @@
 
 
 {% block extra_controls %}
 {% block extra_controls %}
   {% if perms.dcim.add_device %}
   {% if perms.dcim.add_device %}
-    <a href="{% url 'dcim:device_add' %}?role={{ object.pk }}" class="btn btn-sm btn-primary">
+    <a href="{% url 'dcim:device_add' %}?platform={{ object.pk }}" class="btn btn-sm btn-primary">
       <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Device" %}
       <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Device" %}
     </a>
     </a>
   {% endif %}
   {% endif %}

+ 6 - 0
netbox/templates/django/forms/widgets/checkbox.html

@@ -0,0 +1,6 @@
+{% comment %}
+  Include a hidden field of the same name to ensure that unchecked checkboxes
+  are always included in the submitted form data.
+{% endcomment %}
+<input type="hidden" name="{{ widget.name }}" value="">
+{% include "django/forms/widgets/input.html" %}

+ 2 - 0
netbox/templates/generic/bulk_import.html

@@ -67,6 +67,7 @@ Context:
           <input type="hidden" name="import_method" value="upload" />
           <input type="hidden" name="import_method" value="upload" />
           {% render_field form.upload_file %}
           {% render_field form.upload_file %}
           {% render_field form.format %}
           {% render_field form.format %}
+          {% render_field form.csv_delimiter %}
           <div class="form-group">
           <div class="form-group">
             <div class="col col-md-12 text-end">
             <div class="col col-md-12 text-end">
               <button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button>
               <button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button>
@@ -88,6 +89,7 @@ Context:
           {% render_field form.data_source %}
           {% render_field form.data_source %}
           {% render_field form.data_file %}
           {% render_field form.data_file %}
           {% render_field form.format %}
           {% render_field form.format %}
+          {% render_field form.csv_delimiter %}
           <div class="form-group">
           <div class="form-group">
             <div class="col col-md-12 text-end">
             <div class="col col-md-12 text-end">
               <button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button>
               <button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button>

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

@@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView
 from circuits.models import Circuit
 from circuits.models import Circuit
 from dcim.models import Device, Rack, Site
 from dcim.models import Device, Rack, Site
 from ipam.models import IPAddress, Prefix, VLAN, VRF
 from ipam.models import IPAddress, Prefix, VLAN, VRF
-from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
 from tenancy import filtersets
 from tenancy import filtersets
 from tenancy.models import *
 from tenancy.models import *
 from utilities.utils import count_related
 from utilities.utils import count_related
@@ -23,7 +23,7 @@ class TenancyRootView(APIRootView):
 # Tenants
 # Tenants
 #
 #
 
 
-class TenantGroupViewSet(NetBoxModelViewSet):
+class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     queryset = TenantGroup.objects.add_related_count(
     queryset = TenantGroup.objects.add_related_count(
         TenantGroup.objects.all(),
         TenantGroup.objects.all(),
         Tenant,
         Tenant,
@@ -58,7 +58,7 @@ class TenantViewSet(NetBoxModelViewSet):
 # Contacts
 # Contacts
 #
 #
 
 
-class ContactGroupViewSet(NetBoxModelViewSet):
+class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     queryset = ContactGroup.objects.add_related_count(
     queryset = ContactGroup.objects.add_related_count(
         ContactGroup.objects.all(),
         ContactGroup.objects.all(),
         Contact,
         Contact,

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

@@ -114,6 +114,9 @@ class UserTokenForm(BootstrapMixin, forms.ModelForm):
         help_text=_(
         help_text=_(
             'Keys must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to '
             'Keys must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to '
             'submitting this form, as it may no longer be accessible once the token has been created.'
             'submitting this form, as it may no longer be accessible once the token has been created.'
+        ),
+        widget=forms.TextInput(
+            attrs={'data-clipboard': 'true'}
         )
         )
     )
     )
     allowed_ips = SimpleArrayField(
     allowed_ips = SimpleArrayField(

+ 1 - 1
netbox/users/tables.py

@@ -52,7 +52,7 @@ class UserTable(NetBoxTable):
         model = NetBoxUser
         model = NetBoxUser
         fields = (
         fields = (
             'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
             'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
-            'is_superuser',
+            'is_superuser', 'last_login',
         )
         )
         default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active')
         default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active')
 
 

+ 4 - 2
netbox/utilities/counters.py

@@ -62,7 +62,7 @@ def post_save_receiver(sender, instance, created, **kwargs):
             update_counter(parent_model, new_pk, counter_name, 1)
             update_counter(parent_model, new_pk, counter_name, 1)
 
 
 
 
-def post_delete_receiver(sender, instance, **kwargs):
+def post_delete_receiver(sender, instance, origin, **kwargs):
     """
     """
     Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
     Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
     """
     """
@@ -72,7 +72,9 @@ def post_delete_receiver(sender, instance, **kwargs):
 
 
         # Decrement the parent's counter by one
         # Decrement the parent's counter by one
         if parent_pk is not None:
         if parent_pk is not None:
-            update_counter(parent_model, parent_pk, counter_name, -1)
+            # MPTT sends two delete signals for child elements so guard against multiple decrements
+            if not origin or origin == instance:
+                update_counter(parent_model, parent_pk, counter_name, -1)
 
 
 
 
 #
 #

+ 8 - 0
netbox/utilities/templates/form_helpers/render_field.html

@@ -29,6 +29,14 @@
           {{ label }}
           {{ label }}
         </label>
         </label>
       </div>
       </div>
+    {# Include a copy-to-clipboard button #}
+    {% elif 'data-clipboard' in field.field.widget.attrs %}
+      <div class="input-group">
+        {{ field }}
+        <button type="button" title="{% trans "Copy to clipboard" %}" class="btn btn-outline-dark border-input copy-content" data-clipboard-target="#{{ field.id_for_label }}">
+          <i class="mdi mdi-content-copy"></i>
+        </button>
+      </div>
     {# Default field rendering #}
     {# Default field rendering #}
     {% else %}
     {% else %}
       {{ field }}
       {{ field }}

+ 6 - 0
netbox/utilities/templatetags/builtins/tags.py

@@ -1,6 +1,7 @@
 from django import template
 from django import template
 from django.http import QueryDict
 from django.http import QueryDict
 
 
+from extras.choices import CustomFieldTypeChoices
 from utilities.utils import dict_to_querydict
 from utilities.utils import dict_to_querydict
 
 
 __all__ = (
 __all__ = (
@@ -38,6 +39,11 @@ def customfield_value(customfield, value):
         customfield: A CustomField instance
         customfield: A CustomField instance
         value: The custom field value applied to an object
         value: The custom field value applied to an object
     """
     """
+    if value:
+        if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
+            value = customfield.get_choice_label(value)
+        elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+            value = [customfield.get_choice_label(v) for v in value]
     return {
     return {
         'customfield': customfield,
         'customfield': customfield,
         'value': value,
         'value': value,

+ 28 - 3
netbox/utilities/tests/test_counters.py

@@ -1,7 +1,11 @@
-from django.test import TestCase
+from django.contrib.contenttypes.models import ContentType
+from django.test import override_settings
+from django.urls import reverse
 
 
 from dcim.models import *
 from dcim.models import *
-from utilities.testing.utils import create_test_device
+from users.models import ObjectPermission
+from utilities.testing.base import TestCase
+from utilities.testing.utils import create_test_device, create_test_user
 
 
 
 
 class CountersTest(TestCase):
 class CountersTest(TestCase):
@@ -10,7 +14,6 @@ class CountersTest(TestCase):
     """
     """
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-
         # Create devices
         # Create devices
         device1 = create_test_device('Device 1')
         device1 = create_test_device('Device 1')
         device2 = create_test_device('Device 2')
         device2 = create_test_device('Device 2')
@@ -79,3 +82,25 @@ class CountersTest(TestCase):
         device2.refresh_from_db()
         device2.refresh_from_db()
         self.assertEqual(device1.interface_count, 1)
         self.assertEqual(device1.interface_count, 1)
         self.assertEqual(device2.interface_count, 3)
         self.assertEqual(device2.interface_count, 3)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_mptt_child_delete(self):
+        device1, device2 = Device.objects.all()
+        inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1')
+        inventory_item2 = InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1)
+        device1.refresh_from_db()
+        self.assertEqual(device1.inventory_item_count, 2)
+
+        # Setup bulk_delete for the inventory items
+        self.add_permissions('dcim.delete_inventoryitem')
+        pk_list = device1.inventoryitems.values_list('pk', flat=True)
+        data = {
+            'pk': pk_list,
+            'confirm': True,
+            '_confirm': True,  # Form button
+        }
+
+        # Try POST with model-level permission
+        self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
+        device1.refresh_from_db()
+        self.assertEqual(device1.inventory_item_count, 0)

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

@@ -1,6 +1,6 @@
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
 
 
-from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
 from wireless import filtersets
 from wireless import filtersets
 from wireless.models import *
 from wireless.models import *
 from . import serializers
 from . import serializers
@@ -14,7 +14,7 @@ class WirelessRootView(APIRootView):
         return 'Wireless'
         return 'Wireless'
 
 
 
 
-class WirelessLANGroupViewSet(NetBoxModelViewSet):
+class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     queryset = WirelessLANGroup.objects.add_related_count(
     queryset = WirelessLANGroup.objects.add_related_count(
         WirelessLANGroup.objects.all(),
         WirelessLANGroup.objects.all(),
         WirelessLAN,
         WirelessLAN,

+ 0 - 1
netbox/wireless/models.py

@@ -2,7 +2,6 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel
 
 
 from dcim.choices import LinkStatusChoices
 from dcim.choices import LinkStatusChoices
 from dcim.constants import WIRELESS_IFACE_TYPES
 from dcim.constants import WIRELESS_IFACE_TYPES

+ 14 - 13
requirements.txt

@@ -1,34 +1,35 @@
-bleach==6.0.0
-Django==4.2.5
-django-cors-headers==4.2.0
+bleach==6.1.0
+Django==4.2.6
+django-cors-headers==4.3.0
 django-debug-toolbar==4.2.0
 django-debug-toolbar==4.2.0
 django-filter==23.3
 django-filter==23.3
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
-django-mptt==0.14
+django-mptt==0.14.0
 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-rich==1.7.0
+django-redis==5.4.0
+django-rich==1.8.0
 django-rq==2.8.1
 django-rq==2.8.1
 django-tables2==2.6.0
 django-tables2==2.6.0
 django-taggit==4.0.0
 django-taggit==4.0.0
 django-timezone-field==6.0.1
 django-timezone-field==6.0.1
 djangorestframework==3.14.0
 djangorestframework==3.14.0
-drf-spectacular==0.26.4
-drf-spectacular-sidecar==2023.9.1
+drf-spectacular==0.26.5
+drf-spectacular-sidecar==2023.10.1
 feedparser==6.0.10
 feedparser==6.0.10
 graphene-django==3.0.0
 graphene-django==3.0.0
 gunicorn==21.2.0
 gunicorn==21.2.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.3.7
 Markdown==3.3.7
-mkdocs-material==9.4.2
+mkdocs-material==9.4.6
 mkdocstrings[python-legacy]==0.23.0
 mkdocstrings[python-legacy]==0.23.0
 netaddr==0.9.0
 netaddr==0.9.0
-Pillow==10.0.1
-psycopg[binary,pool]==3.1.11
+Pillow==10.1.0
+psycopg[binary,pool]==3.1.12
 PyYAML==6.0.1
 PyYAML==6.0.1
-sentry-sdk==1.31.0
-social-auth-app-django==5.3.0
+requests==2.31.0
+sentry-sdk==1.32.0
+social-auth-app-django==5.4.0
 social-auth-core[openidconnect]==4.4.2
 social-auth-core[openidconnect]==4.4.2
 svgwrite==1.4.3
 svgwrite==1.4.3
 tablib==3.5.0
 tablib==3.5.0

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است