فهرست منبع

Merge pull request #12805 from netbox-community/develop

Release v3.5.3
Jeremy Stretch 2 سال پیش
والد
کامیت
9fb52be85c
40فایلهای تغییر یافته به همراه566 افزوده شده و 158 حذف شده
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 7 4
      .github/ISSUE_TEMPLATE/config.yml
  3. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  4. 0 3
      README.md
  5. 2 1
      base_requirements.txt
  6. 6 5
      docs/models/extras/customfield.md
  7. 29 0
      docs/release-notes/version-3.5.md
  8. 9 4
      netbox/dcim/api/views.py
  9. 22 0
      netbox/dcim/filtersets.py
  10. 33 12
      netbox/dcim/forms/filtersets.py
  11. 1 0
      netbox/dcim/forms/object_create.py
  12. 34 1
      netbox/dcim/tests/test_api.py
  13. 223 66
      netbox/dcim/tests/test_filtersets.py
  14. 2 0
      netbox/extras/choices.py
  15. 7 1
      netbox/extras/conditions.py
  16. 12 12
      netbox/extras/dashboard/widgets.py
  17. 8 6
      netbox/extras/lookups.py
  18. 2 2
      netbox/extras/models/models.py
  19. 11 0
      netbox/extras/tables/tables.py
  20. 12 1
      netbox/ipam/forms/model_forms.py
  21. 26 1
      netbox/netbox/api/viewsets/mixins.py
  22. 10 1
      netbox/netbox/filtersets.py
  23. 9 3
      netbox/netbox/models/features.py
  24. 1 1
      netbox/netbox/settings.py
  25. 6 2
      netbox/netbox/tables/columns.py
  26. 0 0
      netbox/project-static/dist/netbox.js
  27. 0 0
      netbox/project-static/dist/netbox.js.map
  28. 1 0
      netbox/project-static/package.json
  29. 6 1
      netbox/project-static/src/htmx.ts
  30. 3 2
      netbox/project-static/src/select/api/apiSelect.ts
  31. 5 0
      netbox/project-static/yarn.lock
  32. 16 2
      netbox/templates/dcim/device/render_config.html
  33. 2 4
      netbox/templates/extras/dashboard/widgets/objectcounts.html
  34. 2 6
      netbox/templates/inc/panels/image_attachments.html
  35. 17 7
      netbox/tenancy/views.py
  36. 9 1
      netbox/users/signals.py
  37. 4 0
      netbox/utilities/templates/builtins/htmx_table.html
  38. 20 0
      netbox/utilities/templatetags/builtins/tags.py
  39. 1 1
      netbox/utilities/utils.py
  40. 6 6
      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.2
+      placeholder: v3.5.3
     validations:
       required: true
   - type: dropdown

+ 7 - 4
.github/ISSUE_TEMPLATE/config.yml

@@ -3,10 +3,13 @@ blank_issues_enabled: false
 contact_links:
   - name: 📖 Contributing Policy
     url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
-    about: "Please read through our contributing policy before opening an issue or pull request"
+    about: "Please read through our contributing policy before opening an issue or pull request."
   - name: ❓ Discussion
     url: https://github.com/netbox-community/netbox/discussions
-    about: "If you're just looking for help, try starting a discussion instead"
+    about: "If you're just looking for help, try starting a discussion instead."
+  - name: 💡 Plugin Idea
+    url: https://plugin-ideas.netbox.dev
+    about: "Have an idea for a plugin? Head over to the ideas board!"
   - name: 💬 Community Slack
-    url: https://netdev.chat/
-    about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems"
+    url: https://netdev.chat
+    about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems."

+ 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.2
+      placeholder: v3.5.3
     validations:
       required: true
   - type: dropdown

+ 0 - 3
README.md

@@ -1,7 +1,4 @@
 <div align="center">
-  <strong>The :ballot_box_with_check: <a href="https://forms.gle/zUHrrPo7K34yKaqC9">2023 NetBox Community Survey</a> is now open!</strong>
-  <p>Please take a few minutes to tell us about your NetBox deployment.</p>
-
   <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
   <p>The premiere source of truth powering network automation</p>
   <img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />

+ 2 - 1
base_requirements.txt

@@ -84,7 +84,8 @@ feedparser
 
 # Django wrapper for Graphene (GraphQL support)
 # https://github.com/graphql-python/graphene-django/releases
-graphene_django
+# Pinned to v3.0.0 for GraphiQL UI issue (see #12762)
+graphene_django==3.0.0
 
 # WSGI HTTP server
 # https://docs.gunicorn.org/en/latest/news.html

+ 6 - 5
docs/models/extras/customfield.md

@@ -68,11 +68,12 @@ Defines how filters are evaluated against custom field values.
 
 Controls how and whether the custom field is displayed within the NetBox user interface.
 
-| Option     | Description                          |
-|------------|--------------------------------------|
-| Read/write | Display and permit editing (default) |
-| Read-only  | Display field but disallow editing   |
-| Hidden     | Do not display field in the UI       |
+| Option            | Description                                      |
+|-------------------|--------------------------------------------------|
+| Read/write        | Display and permit editing (default)             |
+| Read-only         | Display field but disallow editing               |
+| Hidden            | Do not display field in the UI                   |
+| Hidden (if unset) | Display in the UI only when a value has been set |
 
 ### Default
 

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

@@ -1,5 +1,34 @@
 # NetBox v3.5
 
+## v3.5.3 (2023-06-02)
+
+### Enhancements
+
+* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules
+* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components
+* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration
+* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures
+* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset
+* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets
+
+### Bug Fixes
+
+* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API
+* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value
+* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object
+* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment
+* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables
+* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text
+* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form
+* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object
+* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view
+* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters
+* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version
+* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets
+* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters
+
+---
+
 ## v3.5.2 (2023-05-22)
 
 ### Enhancements

+ 9 - 4
netbox/dcim/api/views.py

@@ -1,12 +1,12 @@
 from django.http import Http404, HttpResponse
 from django.shortcuts import get_object_or_404
-from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
 from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema, OpenApiParameter
 from rest_framework.decorators import action
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
-from rest_framework.status import HTTP_400_BAD_REQUEST
 from rest_framework.routers import APIRootView
+from rest_framework.status import HTTP_400_BAD_REQUEST
 from rest_framework.viewsets import ViewSet
 
 from circuits.models import Circuit
@@ -14,7 +14,6 @@ from dcim import filtersets
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
 from dcim.svg import CableTraceSVG
-from extras.api.nested_serializers import NestedConfigTemplateSerializer
 from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
 from ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -22,6 +21,7 @@ from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.renderers import TextRenderer
 from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
 from utilities.utils import count_related
@@ -386,7 +386,12 @@ class PlatformViewSet(NetBoxModelViewSet):
 # Devices/modules
 #
 
-class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
+class DeviceViewSet(
+    SequentialBulkCreatesMixin,
+    ConfigContextQuerySetMixin,
+    ConfigTemplateRenderMixin,
+    NetBoxModelViewSet
+):
     queryset = Device.objects.prefetch_related(
         'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
         'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',

+ 22 - 0
netbox/dcim/filtersets.py

@@ -1219,6 +1219,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         to_field_name='name',
         label=_('Device (name)'),
     )
+    device_type_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__device_type',
+        queryset=DeviceType.objects.all(),
+        label=_('Device type (ID)'),
+    )
+    device_type = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__device_type__model',
+        queryset=DeviceType.objects.all(),
+        to_field_name='model',
+        label=_('Device type (model)'),
+    )
+    device_role_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__device_role',
+        queryset=DeviceRole.objects.all(),
+        label=_('Device role (ID)'),
+    )
+    device_role = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__device_role__slug',
+        queryset=DeviceRole.objects.all(),
+        to_field_name='slug',
+        label=_('Device role (slug)'),
+    )
     virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device__virtual_chassis',
         queryset=VirtualChassis.objects.all(),

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

@@ -102,13 +102,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
         required=False,
         label=_('Virtual Chassis')
     )
+    device_type_id = DynamicModelMultipleChoiceField(
+        queryset=DeviceType.objects.all(),
+        required=False,
+        label=_('Device type')
+    )
+    device_role_id = DynamicModelMultipleChoiceField(
+        queryset=DeviceRole.objects.all(),
+        required=False,
+        label=_('Device role')
+    )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         required=False,
         query_params={
             'site_id': '$site_id',
             'location_id': '$location_id',
-            'virtual_chassis_id': '$virtual_chassis_id'
+            'virtual_chassis_id': '$virtual_chassis_id',
+            'device_type_id': '$device_type_id',
+            'role_id': '$device_role_id'
         },
         label=_('Device')
     )
@@ -1070,7 +1082,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
     type = forms.MultipleChoiceField(
@@ -1089,7 +1102,8 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
     type = forms.MultipleChoiceField(
@@ -1108,7 +1122,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
     type = forms.MultipleChoiceField(
@@ -1123,7 +1138,8 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
     type = forms.MultipleChoiceField(
@@ -1141,8 +1157,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         ('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
         ('PoE', ('poe_mode', 'poe_type')),
         ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
-                    'device_id', 'vdc_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
     vdc_id = DynamicModelMultipleChoiceField(
@@ -1242,7 +1258,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'color')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         ('Cable', ('cabled', 'occupied')),
     )
     model = FrontPort
@@ -1261,7 +1278,8 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'color')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
         ('Cable', ('cabled', 'occupied')),
     )
     type = forms.MultipleChoiceField(
@@ -1279,7 +1297,8 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'position')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
     )
     tag = TagFilterField(model)
     position = forms.CharField(
@@ -1292,7 +1311,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
     )
     tag = TagFilterField(model)
 
@@ -1302,7 +1322,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+        ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
     )
     role_id = DynamicModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),

+ 1 - 0
netbox/dcim/forms/object_create.py

@@ -101,6 +101,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
         choices=[],
         label=_('Rear ports'),
         help_text=_('Select one rear port assignment for each front port being created.'),
+        widget=forms.SelectMultiple(attrs={'size': 6})
     )
 
     # Override fieldsets from FrontPortTemplateForm to omit rear_port_position

+ 34 - 1
netbox/dcim/tests/test_api.py

@@ -1115,7 +1115,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
 
         device_types = (
             DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
-            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=2),
         )
         DeviceType.objects.bulk_create(device_types)
 
@@ -1229,6 +1229,39 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
 
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
+    def test_rack_fit(self):
+        """
+        Check that creating multiple devices with overlapping position fails.
+        """
+        device = Device.objects.first()
+        device_type = DeviceType.objects.all()[1]
+        data = [
+            {
+                'device_type': device_type.pk,
+                'device_role': device.device_role.pk,
+                'site': device.site.pk,
+                'name': 'Test Device 7',
+                'rack': device.rack.pk,
+                'face': 'front',
+                'position': 1
+            },
+            {
+                'device_type': device_type.pk,
+                'device_role': device.device_role.pk,
+                'site': device.site.pk,
+                'name': 'Test Device 8',
+                'rack': device.rack.pk,
+                'face': 'front',
+                'position': 2
+            }
+        ]
+
+        self.add_permissions('dcim.add_device')
+        url = reverse('dcim-api:device-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
 
 class ModuleTest(APIViewTestCases.APIViewTestCase):
     model = Module

+ 223 - 66
netbox/dcim/tests/test_filtersets.py

@@ -12,6 +12,23 @@ from virtualization.models import Cluster, ClusterType
 from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 
 
+class DeviceComponentFilterSetTests:
+
+    def test_device_type(self):
+        device_types = DeviceType.objects.all()[:2]
+        params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'device_type': [device_types[0].model, device_types[1].model]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_device_role(self):
+        device_role = DeviceRole.objects.all()[:2]
+        params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'device_role': [device_role[0].slug, device_role[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Region.objects.all()
     filterset = RegionFilterSet
@@ -1998,7 +2015,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ConsolePort.objects.all()
     filterset = ConsolePortFilterSet
 
@@ -2027,10 +2044,23 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
             Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
         module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
-        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        device_roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        DeviceRole.objects.bulk_create(device_roles)
 
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2048,10 +2078,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
-            Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+            Device(name=None, device_type=device_types[0], device_role=device_roles[0], site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
 
@@ -2165,7 +2195,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ConsoleServerPort.objects.all()
     filterset = ConsoleServerPortFilterSet
 
@@ -2194,10 +2224,23 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
             Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
         module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
-        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        device_roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        DeviceRole.objects.bulk_create(device_roles)
 
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2215,10 +2258,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
-            Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+            Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
 
@@ -2332,7 +2375,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = PowerPort.objects.all()
     filterset = PowerPortFilterSet
 
@@ -2361,10 +2404,23 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
             Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
         module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
-        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        device_roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        DeviceRole.objects.bulk_create(device_roles)
 
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2382,10 +2438,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
-            Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+            Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
 
@@ -2507,7 +2563,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
+class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = PowerOutlet.objects.all()
     filterset = PowerOutletFilterSet
 
@@ -2536,10 +2592,23 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
             Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
         module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
-        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        device_roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        DeviceRole.objects.bulk_create(device_roles)
 
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2557,10 +2626,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
-            Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+            Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
 
@@ -2678,7 +2747,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
+class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = Interface.objects.all()
     filterset = InterfaceFilterSet
 
@@ -2707,10 +2776,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
             Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
         module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
-        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        device_roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        DeviceRole.objects.bulk_create(device_roles)
 
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2728,10 +2810,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
-            Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+            Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
 
@@ -3101,7 +3183,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
-class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = FrontPort.objects.all()
     filterset = FrontPortFilterSet
 
@@ -3130,10 +3212,23 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
             Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
         module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
-        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        device_roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        DeviceRole.objects.bulk_create(device_roles)
 
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3151,10 +3246,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
-            Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+            Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
 
@@ -3277,7 +3372,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = RearPort.objects.all()
     filterset = RearPortFilterSet
 
@@ -3306,10 +3401,23 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
             Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
         module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
-        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        device_roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        DeviceRole.objects.bulk_create(device_roles)
 
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3327,10 +3435,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
-            Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+            Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
 
@@ -3447,7 +3555,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ModuleBay.objects.all()
     filterset = ModuleBayFilterSet
 
@@ -3476,9 +3584,21 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
             Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
-        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
+        device_roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        DeviceRole.objects.bulk_create(device_roles)
 
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3496,9 +3616,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
         )
         Device.objects.bulk_create(devices)
 
@@ -3564,7 +3684,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
+class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = DeviceBay.objects.all()
     filterset = DeviceBayFilterSet
 
@@ -3593,9 +3713,21 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
             Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
-        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
+        device_roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        DeviceRole.objects.bulk_create(device_roles)
 
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3613,9 +3745,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
         )
         Device.objects.bulk_create(devices)
 
@@ -3694,8 +3826,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         Manufacturer.objects.bulk_create(manufacturers)
 
-        device_type = DeviceType.objects.create(manufacturer=manufacturers[0], model='Model 1', slug='model-1')
-        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
+        device_roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        DeviceRole.objects.bulk_create(device_roles)
 
         regions = (
             Region(name='Region 1', slug='region-1'),
@@ -3736,9 +3879,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
         )
         Device.objects.bulk_create(devices)
 
@@ -3829,6 +3972,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'rack': [racks[0].name, racks[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_device_type(self):
+        device_types = DeviceType.objects.all()[:2]
+        params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'device_type': [device_types[0].model, device_types[1].model]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_device_role(self):
+        device_role = DeviceRole.objects.all()[:2]
+        params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'device_role': [device_role[0].slug, device_role[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}

+ 2 - 0
netbox/extras/choices.py

@@ -56,11 +56,13 @@ class CustomFieldVisibilityChoices(ChoiceSet):
     VISIBILITY_READ_WRITE = 'read-write'
     VISIBILITY_READ_ONLY = 'read-only'
     VISIBILITY_HIDDEN = 'hidden'
+    VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset'
 
     CHOICES = (
         (VISIBILITY_READ_WRITE, 'Read/Write'),
         (VISIBILITY_READ_ONLY, 'Read-only'),
         (VISIBILITY_HIDDEN, 'Hidden'),
+        (VISIBILITY_HIDDEN_IFUNSET, 'Hidden (if unset)'),
     )
 
 

+ 7 - 1
netbox/extras/conditions.py

@@ -65,8 +65,14 @@ class Condition:
         """
         Evaluate the provided data to determine whether it matches the condition.
         """
+        def _get(obj, key):
+            if isinstance(obj, list):
+                return [dict.get(i, key) for i in obj]
+
+            return dict.get(obj, key)
+
         try:
-            value = functools.reduce(dict.get, self.attr.split('.'), data)
+            value = functools.reduce(_get, self.attr.split('.'), data)
         except TypeError:
             # Invalid key path
             value = None

+ 12 - 12
netbox/extras/dashboard/widgets.py

@@ -10,8 +10,9 @@ 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, reverse
+from django.urls import NoReverseMatch, resolve, reverse
 from django.utils.translation import gettext as _
 
 from extras.utils import FeatureQuery
@@ -149,7 +150,7 @@ class ObjectCountsWidget(DashboardWidget):
         filters = forms.JSONField(
             required=False,
             label='Object filters',
-            help_text=_("Only objects matching the specified filters will be counted")
+            help_text=_("Filters to apply when counting the number of objects")
         )
 
         def clean_filters(self):
@@ -158,13 +159,6 @@ class ObjectCountsWidget(DashboardWidget):
                     dict(data)
                 except TypeError:
                     raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
-                for model in get_models_from_content_types(self.cleaned_data.get('models')):
-                    try:
-                        # Validate the filters by creating a QuerySet
-                        model.objects.filter(**data).none()
-                    except Exception:
-                        model_name = model._meta.verbose_name_plural
-                        raise forms.ValidationError(f"Invalid filter specification for {model_name}.")
             return data
 
     def render(self, request):
@@ -172,13 +166,19 @@ class ObjectCountsWidget(DashboardWidget):
         for model in get_models_from_content_types(self.config['models']):
             permission = get_permission_for_model(model, 'view')
             if request.user.has_perm(permission):
+                url = reverse(get_viewname(model, 'list'))
                 qs = model.objects.restrict(request.user, 'view')
+                # Apply any specified filters
                 if filters := self.config.get('filters'):
-                    qs = qs.filter(**filters)
+                    params = QueryDict(mutable=True)
+                    params.update(filters)
+                    filterset = getattr(resolve(url).func.view_class, 'filterset', None)
+                    qs = filterset(params, qs).qs
+                    url = f'{url}?{params.urlencode()}'
                 object_count = qs.count
-                counts.append((model, object_count))
+                counts.append((model, object_count, url))
             else:
-                counts.append((model, None))
+                counts.append((model, None, None))
 
         return render_to_string(self.template_name, {
             'counts': counts,

+ 8 - 6
netbox/extras/lookups.py

@@ -7,12 +7,14 @@ class Empty(Lookup):
     Filter on whether a string is empty.
     """
     lookup_name = 'empty'
-
-    def as_sql(self, qn, connection):
-        lhs, lhs_params = self.process_lhs(qn, connection)
-        rhs, rhs_params = self.process_rhs(qn, connection)
-        params = lhs_params + rhs_params
-        return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
+    prepare_rhs = False
+
+    def as_sql(self, compiler, connection):
+        sql, params = compiler.compile(self.lhs)
+        if self.rhs:
+            return f"CAST(LENGTH({sql}) AS BOOLEAN) IS NOT TRUE", params
+        else:
+            return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
 
 
 class NetContainsOrEquals(Lookup):

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

@@ -274,10 +274,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
         :param context: The context passed to Jinja2
         """
-        text = render_jinja2(self.link_text, context)
+        text = render_jinja2(self.link_text, context).strip()
         if not text:
             return {}
-        link = render_jinja2(self.link_url, context)
+        link = render_jinja2(self.link_url, context).strip()
         link_target = ' target="_blank"' if self.new_window else ''
 
         # Sanitize link text

+ 11 - 0
netbox/extras/tables/tables.py

@@ -22,6 +22,14 @@ __all__ = (
     'WebhookTable',
 )
 
+IMAGEATTACHMENT_IMAGE = '''
+{% if record.image %}
+  <a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
+{% else %}
+  &mdash;
+{% endif %}
+'''
+
 
 class CustomFieldTable(NetBoxTable):
     name = tables.Column(
@@ -96,6 +104,9 @@ class ImageAttachmentTable(NetBoxTable):
     parent = tables.Column(
         linkify=True
     )
+    image = tables.TemplateColumn(
+        template_code=IMAGEATTACHMENT_IMAGE,
+    )
     size = tables.Column(
         orderable=False,
         verbose_name='Size (bytes)'

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

@@ -328,6 +328,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
             ):
                 self.initial['primary_for_parent'] = True
 
+        # Disable object assignment fields if the IP address is designated as primary
+        if self.initial.get('primary_for_parent'):
+            self.fields['interface'].disabled = True
+            self.fields['vminterface'].disabled = True
+            self.fields['fhrpgroup'].disabled = True
+
     def clean(self):
         super().clean()
 
@@ -340,7 +346,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
                 selected_objects[1]: "An IP address can only be assigned to a single object."
             })
         elif selected_objects:
-            self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
+            assigned_object = self.cleaned_data[selected_objects[0]]
+            if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
+                raise ValidationError(
+                    "Cannot reassign IP address while it is designated as the primary IP for the parent object"
+                )
+            self.instance.assigned_object = assigned_object
         else:
             self.instance.assigned_object = None
 

+ 26 - 1
netbox/netbox/api/viewsets/mixins.py

@@ -15,11 +15,12 @@ from utilities.api import get_serializer_for_model
 
 __all__ = (
     'BriefModeMixin',
+    'BulkDestroyModelMixin',
     'BulkUpdateModelMixin',
     'CustomFieldsMixin',
     'ExportTemplatesMixin',
-    'BulkDestroyModelMixin',
     'ObjectValidationMixin',
+    'SequentialBulkCreatesMixin',
 )
 
 
@@ -94,6 +95,30 @@ class ExportTemplatesMixin:
         return super().list(request, *args, **kwargs)
 
 
+class SequentialBulkCreatesMixin:
+    """
+    Perform bulk creation of new objects sequentially, rather than all at once. This ensures that any validation
+    which depends on the evaluation of existing objects (such as checking for free space within a rack) functions
+    appropriately.
+    """
+    @transaction.atomic
+    def create(self, request, *args, **kwargs):
+        if not isinstance(request.data, list):
+            # Creating a single object
+            return super().create(request, *args, **kwargs)
+
+        return_data = []
+        for data in request.data:
+            serializer = self.get_serializer(data=data)
+            serializer.is_valid(raise_exception=True)
+            self.perform_create(serializer)
+            return_data.append(serializer.data)
+
+        headers = self.get_success_headers(serializer.data)
+
+        return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
+
+
 class BulkUpdateModelMixin:
     """
     Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one

+ 10 - 1
netbox/netbox/filtersets.py

@@ -177,7 +177,8 @@ class BaseFilterSet(django_filters.FilterSet):
                     # create the new filter with the same type because there is no guarantee the defined type
                     # is the same as the default type for the field
                     resolve_field(field, lookup_expr)  # Will raise FieldLookupError if the lookup is invalid
-                    new_filter = type(existing_filter)(
+                    filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter)
+                    new_filter = filter_cls(
                         field_name=field_name,
                         lookup_expr=lookup_expr,
                         label=existing_filter.label,
@@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet):
 
         return filters
 
+    @classmethod
+    def filter_for_lookup(cls, field, lookup_type):
+
+        if lookup_type == 'empty':
+            return django_filters.BooleanFilter, {}
+
+        return super().filter_for_lookup(field, lookup_type)
+
 
 class ChangeLoggedModelFilterSet(BaseFilterSet):
     """

+ 9 - 3
netbox/netbox/models/features.py

@@ -197,11 +197,15 @@ class CustomFieldsMixin(models.Model):
         data = {}
 
         for field in CustomField.objects.get_for_model(self):
+            value = self.custom_field_data.get(field.name)
+
             # Skip fields that are hidden if 'omit_hidden' is set
-            if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
-                continue
+            if omit_hidden:
+                if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
+                    continue
+                if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value:
+                    continue
 
-            value = self.custom_field_data.get(field.name)
             data[field] = field.deserialize(value)
 
         return data
@@ -227,6 +231,8 @@ class CustomFieldsMixin(models.Model):
 
         for cf in visible_custom_fields:
             value = self.custom_field_data.get(cf.name)
+            if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET:
+                continue
             value = cf.deserialize(value)
             groups[cf.group_name][cf] = value
 

+ 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.2'
+VERSION = '3.5.3'
 
 # Hostname
 HOSTNAME = platform.node()

+ 6 - 2
netbox/netbox/tables/columns.py

@@ -234,8 +234,12 @@ class ActionsColumn(tables.Column):
             return ''
 
         model = table.Meta.model
-        request = getattr(table, 'context', {}).get('request')
-        url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
+        if request := getattr(table, 'context', {}).get('request'):
+            return_url = request.GET.get('return_url', request.get_full_path())
+            url_appendix = f'?return_url={quote(return_url)}'
+        else:
+            url_appendix = ''
+
         html = ''
 
         # Compile actions menu

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 1 - 0
netbox/project-static/package.json

@@ -30,6 +30,7 @@
     "dayjs": "^1.11.5",
     "flatpickr": "4.6.13",
     "gridstack": "^7.2.3",
+    "html-entities": "^2.3.3",
     "htmx.org": "^1.8.0",
     "just-debounce-it": "^3.1.1",
     "query-string": "^7.1.1",

+ 6 - 1
netbox/project-static/src/htmx.ts

@@ -2,9 +2,10 @@ import { getElements, isTruthy } from './util';
 import { initButtons } from './buttons';
 import { initSelect } from './select';
 import { initObjectSelector } from './objectSelector';
+import { initBootstrap } from './bs';
 
 function initDepedencies(): void {
-  for (const init of [initButtons, initSelect, initObjectSelector]) {
+  for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
     init();
   }
 }
@@ -22,4 +23,8 @@ export function initHtmx(): void {
       }
     }
   }
+
+  for (const element of getElements('[hx-trigger=load]')) {
+    element.addEventListener('htmx:afterSettle', initDepedencies);
+  }
 }

+ 3 - 2
netbox/project-static/src/select/api/apiSelect.ts

@@ -1,5 +1,6 @@
 import { readableColor } from 'color2k';
 import debounce from 'just-debounce-it';
+import { encode } from 'html-entities';
 import queryString from 'query-string';
 import SlimSelect from 'slim-select';
 import { createToast } from '../../bs';
@@ -446,7 +447,7 @@ export class APISelect {
     // Build SlimSelect options from all already-selected options.
     const preSelectedOptions = preSelected.map(option => ({
       value: option.value,
-      text: option.innerText,
+      text: encode(option.innerText),
       selected: true,
       disabled: false,
     })) as Option[];
@@ -454,7 +455,7 @@ export class APISelect {
     let options = [] as Option[];
 
     for (const result of data.results) {
-      let text = result.display;
+      let text = encode(result.display);
 
       if (typeof result._depth === 'number' && result._depth > 0) {
         // If the object has a `_depth` property, indent its display text.

+ 5 - 0
netbox/project-static/yarn.lock

@@ -1818,6 +1818,11 @@ has@^1.0.3:
   dependencies:
     function-bind "^1.1.1"
 
+html-entities@^2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46"
+  integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==
+
 htmx.org@^1.8.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3"

+ 16 - 2
netbox/templates/dcim/device/render_config.html

@@ -28,8 +28,22 @@
     </div>
     <div class="col-7">
       <div class="card">
-        <h5 class="card-header">Context Data</h5>
-        <pre class="card-body">{{ context_data|pprint }}</pre>
+        <div class="accordion accordion-flush" id="renderConfig">
+          <div class="card-body">
+            <div class="accordion-item">
+              <h2 class="accordion-header" id="renderConfigHeading">
+                <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsedRenderConfig" aria-expanded="false" aria-controls="collapsedRenderConfig">
+                  Context Data
+                </button>
+              </h2>
+              <div id="collapsedRenderConfig" class="accordion-collapse collapse" aria-labelledby="renderConfigHeading" data-bs-parent="#renderConfig">
+                <div class="accordion-body">
+                  <pre class="card-body">{{ context_data|pprint }}</pre>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
       </div>
     </div>
   </div>

+ 2 - 4
netbox/templates/extras/dashboard/widgets/objectcounts.html

@@ -1,10 +1,8 @@
-{% load helpers %}
-
 {% if counts %}
   <div class="list-group list-group-flush">
-    {% for model, count in counts %}
+    {% for model, count, url in counts %}
       {% if count != None %}
-        <a href="{% url model|viewname:"list" %}" class="list-group-item list-group-item-action">
+        <a href="{{ url }}" class="list-group-item list-group-item-action">
           <div class="d-flex w-100 justify-content-between align-items-center">
             {{ model|meta:"verbose_name_plural"|bettertitle }}
             <h6 class="mb-1">{{ count }}</h6>

+ 2 - 6
netbox/templates/inc/panels/image_attachments.html

@@ -1,12 +1,8 @@
 {% load helpers %}
 
 <div class="card">
-  <h5 class="card-header">
-    Images
-  </h5>
-  <div class="card-body htmx-container table-responsive"
-  hx-get="{% url 'extras:imageattachment_list' %}?content_type_id={{ object|content_type_id }}&object_id={{ object.pk }}"
-  hx-trigger="load"></div>
+  <h5 class="card-header">Images</h5>
+  {% htmx_table 'extras:imageattachment_list' content_type_id=object|content_type_id object_id=object.pk %}
   {% if perms.extras.add_imageattachment %}
     <div class="card-footer text-end noprint">
       <a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">

+ 17 - 7
netbox/tenancy/views.py

@@ -15,21 +15,31 @@ from .models import *
 
 
 class ObjectContactsView(generic.ObjectChildrenView):
-    child_model = Contact
-    table = tables.ContactTable
-    filterset = filtersets.ContactFilterSet
+    child_model = ContactAssignment
+    table = tables.ContactAssignmentTable
+    filterset = filtersets.ContactAssignmentFilterSet
     template_name = 'tenancy/object_contacts.html'
     tab = ViewTab(
         label=_('Contacts'),
         badge=lambda obj: obj.contacts.count(),
-        permission='tenancy.view_contact',
+        permission='tenancy.view_contactassignment',
         weight=5000
     )
 
     def get_children(self, request, parent):
-        return Contact.objects.annotate(
-            assignment_count=count_related(ContactAssignment, 'contact')
-        ).restrict(request.user, 'view').filter(assignments__object_id=parent.pk)
+        return ContactAssignment.objects.restrict(request.user, 'view').filter(
+            content_type=ContentType.objects.get_for_model(parent),
+            object_id=parent.pk
+        )
+
+    def get_table(self, *args, **kwargs):
+        table = super().get_table(*args, **kwargs)
+
+        # Hide object columns
+        table.columns.hide('content_type')
+        table.columns.hide('object')
+
+        return table
 
     def get_extra_context(self, request, instance):
         return {

+ 9 - 1
netbox/users/signals.py

@@ -1,10 +1,18 @@
 import logging
 from django.dispatch import receiver
 from django.contrib.auth.signals import user_login_failed
+from utilities.request import get_client_ip
 
 
 @receiver(user_login_failed)
 def log_user_login_failed(sender, credentials, request, **kwargs):
     logger = logging.getLogger('netbox.auth.login')
     username = credentials.get("username")
-    logger.info(f"Failed login attempt for username: {username}")
+    if client_ip := get_client_ip(request):
+        logger.info(f"Failed login attempt for username: {username} from {client_ip}")
+    else:
+        logger.warning(
+            "Client IP address could not be determined for validation. Check that the HTTP server is properly "
+            "configured to pass the required header(s)."
+        )
+        logger.info(f"Failed login attempt for username: {username}")

+ 4 - 0
netbox/utilities/templates/builtins/htmx_table.html

@@ -0,0 +1,4 @@
+<div class="card-body htmx-container table-responsive"
+  hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}"
+  hx-trigger="load"
+></div>

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

@@ -1,4 +1,5 @@
 from django import template
+from django.http import QueryDict
 
 __all__ = (
     'badge',
@@ -74,3 +75,22 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
         'true_label': true,
         'false_label': false,
     }
+
+
+@register.inclusion_tag('builtins/htmx_table.html', takes_context=True)
+def htmx_table(context, viewname, return_url=None, **kwargs):
+    """
+    Embed an object list table retrieved using HTMX. Any extra keyword arguments are passed as URL query parameters.
+
+    Args:
+        context: The current request context
+        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['return_url'] = return_url or context['request'].path
+    return {
+        'viewname': viewname,
+        'url_params': url_params,
+    }

+ 1 - 1
netbox/utilities/utils.py

@@ -302,7 +302,7 @@ def to_meters(length, unit):
     if unit == CableLengthUnitChoices.UNIT_FOOT:
         return length * Decimal(0.3048)
     if unit == CableLengthUnitChoices.UNIT_INCH:
-        return length * Decimal(0.3048) * 12
+        return length * Decimal(0.0254)
     raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.")
 
 

+ 6 - 6
requirements.txt

@@ -1,5 +1,5 @@
 bleach==6.0.0
-boto3==1.26.138
+boto3==1.26.145
 Django==4.1.9
 django-cors-headers==4.0.0
 django-debug-toolbar==4.1.0
@@ -16,20 +16,20 @@ django-taggit==4.0.0
 django-timezone-field==5.0
 djangorestframework==3.14.0
 drf-spectacular==0.26.2
-drf-spectacular-sidecar==2023.5.1
+drf-spectacular-sidecar==2023.6.1
 dulwich==0.21.5
 feedparser==6.0.10
-graphene-django==3.0.2
+graphene-django==3.0.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Markdown==3.3.7
-mkdocs-material==9.1.14
-mkdocstrings[python-legacy]==0.21.2
+mkdocs-material==9.1.15
+mkdocstrings[python-legacy]==0.22.0
 netaddr==0.8.0
 Pillow==9.5.0
 psycopg2-binary==2.9.6
 PyYAML==6.0
-sentry-sdk==1.23.1
+sentry-sdk==1.25.0
 social-auth-app-django==5.2.0
 social-auth-core[openidconnect]==4.4.2
 svgwrite==1.4.3

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