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

Merge pull request #12805 from netbox-community/develop

Release v3.5.3
Jeremy Stretch 2 лет назад
Родитель
Сommit
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:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.5.2
+      placeholder: v3.5.3
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

@@ -3,10 +3,13 @@ blank_issues_enabled: false
 contact_links:
 contact_links:
   - name: 📖 Contributing Policy
   - name: 📖 Contributing Policy
     url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
     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
   - name: ❓ Discussion
     url: https://github.com/netbox-community/netbox/discussions
     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
   - 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:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.5.2
+      placeholder: v3.5.3
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 0 - 3
README.md

@@ -1,7 +1,4 @@
 <div align="center">
 <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" />
   <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>
   <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" />
   <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)
 # Django wrapper for Graphene (GraphQL support)
 # https://github.com/graphql-python/graphene-django/releases
 # 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
 # WSGI HTTP server
 # https://docs.gunicorn.org/en/latest/news.html
 # 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.
 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
 ### Default
 
 

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

@@ -1,5 +1,34 @@
 # NetBox v3.5
 # 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)
 ## v3.5.2 (2023-05-22)
 
 
 ### Enhancements
 ### Enhancements

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

@@ -1,12 +1,12 @@
 from django.http import Http404, HttpResponse
 from django.http import Http404, HttpResponse
 from django.shortcuts import get_object_or_404
 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.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema, OpenApiParameter
 from rest_framework.decorators import action
 from rest_framework.decorators import action
 from rest_framework.renderers import JSONRenderer
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework.response import Response
-from rest_framework.status import HTTP_400_BAD_REQUEST
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
+from rest_framework.status import HTTP_400_BAD_REQUEST
 from rest_framework.viewsets import ViewSet
 from rest_framework.viewsets import ViewSet
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
@@ -14,7 +14,6 @@ from dcim import filtersets
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
 from dcim.models import *
 from dcim.svg import CableTraceSVG
 from dcim.svg import CableTraceSVG
-from extras.api.nested_serializers import NestedConfigTemplateSerializer
 from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
 from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
 from ipam.models import Prefix, VLAN
 from ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 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.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
+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
 from utilities.utils import count_related
 from utilities.utils import count_related
@@ -386,7 +386,12 @@ class PlatformViewSet(NetBoxModelViewSet):
 # Devices/modules
 # Devices/modules
 #
 #
 
 
-class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
+class DeviceViewSet(
+    SequentialBulkCreatesMixin,
+    ConfigContextQuerySetMixin,
+    ConfigTemplateRenderMixin,
+    NetBoxModelViewSet
+):
     queryset = Device.objects.prefetch_related(
     queryset = Device.objects.prefetch_related(
         'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
         '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',
         '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',
         to_field_name='name',
         label=_('Device (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(
     virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device__virtual_chassis',
         field_name='device__virtual_chassis',
         queryset=VirtualChassis.objects.all(),
         queryset=VirtualChassis.objects.all(),

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

@@ -102,13 +102,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('Virtual Chassis')
         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(
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
         query_params={
         query_params={
             'site_id': '$site_id',
             'site_id': '$site_id',
             'location_id': '$location_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')
         label=_('Device')
     )
     )
@@ -1070,7 +1082,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('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')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1089,7 +1102,8 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('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')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1108,7 +1122,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type')),
         ('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')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1123,7 +1138,8 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type')),
         ('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')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1141,8 +1157,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         ('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
         ('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
         ('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')),
-        ('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')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
     )
     vdc_id = DynamicModelMultipleChoiceField(
     vdc_id = DynamicModelMultipleChoiceField(
@@ -1242,7 +1258,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('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')),
         ('Cable', ('cabled', 'occupied')),
     )
     )
     model = FrontPort
     model = FrontPort
@@ -1261,7 +1278,8 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('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')),
         ('Cable', ('cabled', 'occupied')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1279,7 +1297,8 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label', 'position')),
         ('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)
     tag = TagFilterField(model)
     position = forms.CharField(
     position = forms.CharField(
@@ -1292,7 +1311,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         ('Attributes', ('name', 'label')),
         ('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)
     tag = TagFilterField(model)
 
 
@@ -1302,7 +1322,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
         (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')),
-        ('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(
     role_id = DynamicModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),
         queryset=InventoryItemRole.objects.all(),

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

@@ -101,6 +101,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
         choices=[],
         choices=[],
         label=_('Rear ports'),
         label=_('Rear ports'),
         help_text=_('Select one rear port assignment for each front port being created.'),
         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
     # 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 = (
         device_types = (
             DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
             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)
         DeviceType.objects.bulk_create(device_types)
 
 
@@ -1229,6 +1229,39 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
 
 
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         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):
 class ModuleTest(APIViewTestCases.APIViewTestCase):
     model = Module
     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
 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):
 class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Region.objects.all()
     queryset = Region.objects.all()
     filterset = RegionFilterSet
     filterset = RegionFilterSet
@@ -1998,7 +2015,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
     filterset = ConsolePortFilterSet
     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 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         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')
         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 = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2048,10 +2078,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         devices = (
         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)
         Device.objects.bulk_create(devices)
 
 
@@ -2165,7 +2195,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
     filterset = ConsoleServerPortFilterSet
     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 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         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')
         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 = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2215,10 +2258,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         devices = (
         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)
         Device.objects.bulk_create(devices)
 
 
@@ -2332,7 +2375,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
     filterset = PowerPortFilterSet
     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 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         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')
         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 = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2382,10 +2438,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         devices = (
         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)
         Device.objects.bulk_create(devices)
 
 
@@ -2507,7 +2563,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
+class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
     filterset = PowerOutletFilterSet
     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 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         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')
         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 = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2557,10 +2626,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         devices = (
         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)
         Device.objects.bulk_create(devices)
 
 
@@ -2678,7 +2747,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
+class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
     filterset = InterfaceFilterSet
     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 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         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')
         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 = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2728,10 +2810,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         devices = (
         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)
         Device.objects.bulk_create(devices)
 
 
@@ -3101,7 +3183,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
 
 
-class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
     filterset = FrontPortFilterSet
     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 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         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')
         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 = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3151,10 +3246,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         devices = (
         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)
         Device.objects.bulk_create(devices)
 
 
@@ -3277,7 +3372,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
     filterset = RearPortFilterSet
     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 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         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')
         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 = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3327,10 +3435,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         devices = (
         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)
         Device.objects.bulk_create(devices)
 
 
@@ -3447,7 +3555,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ModuleBay.objects.all()
     queryset = ModuleBay.objects.all()
     filterset = ModuleBayFilterSet
     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 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         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 = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3496,9 +3616,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         devices = (
         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)
         Device.objects.bulk_create(devices)
 
 
@@ -3564,7 +3684,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
+class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
     filterset = DeviceBayFilterSet
     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 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         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 = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3613,9 +3745,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         devices = (
         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)
         Device.objects.bulk_create(devices)
 
 
@@ -3694,8 +3826,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         Manufacturer.objects.bulk_create(manufacturers)
         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 = (
         regions = (
             Region(name='Region 1', slug='region-1'),
             Region(name='Region 1', slug='region-1'),
@@ -3736,9 +3879,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         devices = (
         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)
         Device.objects.bulk_create(devices)
 
 
@@ -3829,6 +3972,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'rack': [racks[0].name, racks[1].name]}
         params = {'rack': [racks[0].name, racks[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         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):
     def test_device(self):
         devices = Device.objects.all()[:2]
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}
         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_WRITE = 'read-write'
     VISIBILITY_READ_ONLY = 'read-only'
     VISIBILITY_READ_ONLY = 'read-only'
     VISIBILITY_HIDDEN = 'hidden'
     VISIBILITY_HIDDEN = 'hidden'
+    VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset'
 
 
     CHOICES = (
     CHOICES = (
         (VISIBILITY_READ_WRITE, 'Read/Write'),
         (VISIBILITY_READ_WRITE, 'Read/Write'),
         (VISIBILITY_READ_ONLY, 'Read-only'),
         (VISIBILITY_READ_ONLY, 'Read-only'),
         (VISIBILITY_HIDDEN, 'Hidden'),
         (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.
         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:
         try:
-            value = functools.reduce(dict.get, self.attr.split('.'), data)
+            value = functools.reduce(_get, self.attr.split('.'), data)
         except TypeError:
         except TypeError:
             # Invalid key path
             # Invalid key path
             value = None
             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.contrib.contenttypes.models import ContentType
 from django.core.cache import cache
 from django.core.cache import cache
 from django.db.models import Q
 from django.db.models import Q
+from django.http import QueryDict
 from django.template.loader import render_to_string
 from django.template.loader import render_to_string
-from django.urls import NoReverseMatch, reverse
+from django.urls import NoReverseMatch, resolve, reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
@@ -149,7 +150,7 @@ class ObjectCountsWidget(DashboardWidget):
         filters = forms.JSONField(
         filters = forms.JSONField(
             required=False,
             required=False,
             label='Object filters',
             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):
         def clean_filters(self):
@@ -158,13 +159,6 @@ class ObjectCountsWidget(DashboardWidget):
                     dict(data)
                     dict(data)
                 except TypeError:
                 except TypeError:
                     raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
                     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
             return data
 
 
     def render(self, request):
     def render(self, request):
@@ -172,13 +166,19 @@ class ObjectCountsWidget(DashboardWidget):
         for model in get_models_from_content_types(self.config['models']):
         for model in get_models_from_content_types(self.config['models']):
             permission = get_permission_for_model(model, 'view')
             permission = get_permission_for_model(model, 'view')
             if request.user.has_perm(permission):
             if request.user.has_perm(permission):
+                url = reverse(get_viewname(model, 'list'))
                 qs = model.objects.restrict(request.user, 'view')
                 qs = model.objects.restrict(request.user, 'view')
+                # Apply any specified filters
                 if filters := self.config.get('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
                 object_count = qs.count
-                counts.append((model, object_count))
+                counts.append((model, object_count, url))
             else:
             else:
-                counts.append((model, None))
+                counts.append((model, None, None))
 
 
         return render_to_string(self.template_name, {
         return render_to_string(self.template_name, {
             'counts': counts,
             'counts': counts,

+ 8 - 6
netbox/extras/lookups.py

@@ -7,12 +7,14 @@ class Empty(Lookup):
     Filter on whether a string is empty.
     Filter on whether a string is empty.
     """
     """
     lookup_name = '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):
 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
         :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:
         if not text:
             return {}
             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 ''
         link_target = ' target="_blank"' if self.new_window else ''
 
 
         # Sanitize link text
         # Sanitize link text

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

@@ -22,6 +22,14 @@ __all__ = (
     'WebhookTable',
     'WebhookTable',
 )
 )
 
 
+IMAGEATTACHMENT_IMAGE = '''
+{% if record.image %}
+  <a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
+{% else %}
+  &mdash;
+{% endif %}
+'''
+
 
 
 class CustomFieldTable(NetBoxTable):
 class CustomFieldTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
@@ -96,6 +104,9 @@ class ImageAttachmentTable(NetBoxTable):
     parent = tables.Column(
     parent = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    image = tables.TemplateColumn(
+        template_code=IMAGEATTACHMENT_IMAGE,
+    )
     size = tables.Column(
     size = tables.Column(
         orderable=False,
         orderable=False,
         verbose_name='Size (bytes)'
         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
                 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):
     def clean(self):
         super().clean()
         super().clean()
 
 
@@ -340,7 +346,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
                 selected_objects[1]: "An IP address can only be assigned to a single object."
                 selected_objects[1]: "An IP address can only be assigned to a single object."
             })
             })
         elif selected_objects:
         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:
         else:
             self.instance.assigned_object = None
             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__ = (
 __all__ = (
     'BriefModeMixin',
     'BriefModeMixin',
+    'BulkDestroyModelMixin',
     'BulkUpdateModelMixin',
     'BulkUpdateModelMixin',
     'CustomFieldsMixin',
     'CustomFieldsMixin',
     'ExportTemplatesMixin',
     'ExportTemplatesMixin',
-    'BulkDestroyModelMixin',
     'ObjectValidationMixin',
     'ObjectValidationMixin',
+    'SequentialBulkCreatesMixin',
 )
 )
 
 
 
 
@@ -94,6 +95,30 @@ class ExportTemplatesMixin:
         return super().list(request, *args, **kwargs)
         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:
 class BulkUpdateModelMixin:
     """
     """
     Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
     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
                     # 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
                     # is the same as the default type for the field
                     resolve_field(field, lookup_expr)  # Will raise FieldLookupError if the lookup is invalid
                     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,
                         field_name=field_name,
                         lookup_expr=lookup_expr,
                         lookup_expr=lookup_expr,
                         label=existing_filter.label,
                         label=existing_filter.label,
@@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet):
 
 
         return filters
         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):
 class ChangeLoggedModelFilterSet(BaseFilterSet):
     """
     """

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

@@ -197,11 +197,15 @@ class CustomFieldsMixin(models.Model):
         data = {}
         data = {}
 
 
         for field in CustomField.objects.get_for_model(self):
         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
             # 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)
             data[field] = field.deserialize(value)
 
 
         return data
         return data
@@ -227,6 +231,8 @@ class CustomFieldsMixin(models.Model):
 
 
         for cf in visible_custom_fields:
         for cf in visible_custom_fields:
             value = self.custom_field_data.get(cf.name)
             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)
             value = cf.deserialize(value)
             groups[cf.group_name][cf] = 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
 # Environment setup
 #
 #
 
 
-VERSION = '3.5.2'
+VERSION = '3.5.3'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

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

@@ -234,8 +234,12 @@ class ActionsColumn(tables.Column):
             return ''
             return ''
 
 
         model = table.Meta.model
         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 = ''
         html = ''
 
 
         # Compile actions menu
         # 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",
     "dayjs": "^1.11.5",
     "flatpickr": "4.6.13",
     "flatpickr": "4.6.13",
     "gridstack": "^7.2.3",
     "gridstack": "^7.2.3",
+    "html-entities": "^2.3.3",
     "htmx.org": "^1.8.0",
     "htmx.org": "^1.8.0",
     "just-debounce-it": "^3.1.1",
     "just-debounce-it": "^3.1.1",
     "query-string": "^7.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 { initButtons } from './buttons';
 import { initSelect } from './select';
 import { initSelect } from './select';
 import { initObjectSelector } from './objectSelector';
 import { initObjectSelector } from './objectSelector';
+import { initBootstrap } from './bs';
 
 
 function initDepedencies(): void {
 function initDepedencies(): void {
-  for (const init of [initButtons, initSelect, initObjectSelector]) {
+  for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
     init();
     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 { readableColor } from 'color2k';
 import debounce from 'just-debounce-it';
 import debounce from 'just-debounce-it';
+import { encode } from 'html-entities';
 import queryString from 'query-string';
 import queryString from 'query-string';
 import SlimSelect from 'slim-select';
 import SlimSelect from 'slim-select';
 import { createToast } from '../../bs';
 import { createToast } from '../../bs';
@@ -446,7 +447,7 @@ export class APISelect {
     // Build SlimSelect options from all already-selected options.
     // Build SlimSelect options from all already-selected options.
     const preSelectedOptions = preSelected.map(option => ({
     const preSelectedOptions = preSelected.map(option => ({
       value: option.value,
       value: option.value,
-      text: option.innerText,
+      text: encode(option.innerText),
       selected: true,
       selected: true,
       disabled: false,
       disabled: false,
     })) as Option[];
     })) as Option[];
@@ -454,7 +455,7 @@ export class APISelect {
     let options = [] as Option[];
     let options = [] as Option[];
 
 
     for (const result of data.results) {
     for (const result of data.results) {
-      let text = result.display;
+      let text = encode(result.display);
 
 
       if (typeof result._depth === 'number' && result._depth > 0) {
       if (typeof result._depth === 'number' && result._depth > 0) {
         // If the object has a `_depth` property, indent its display text.
         // 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:
   dependencies:
     function-bind "^1.1.1"
     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:
 htmx.org@^1.8.0:
   version "1.8.0"
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3"
   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>
     <div class="col-7">
     <div class="col-7">
       <div class="card">
       <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>
     </div>
   </div>
   </div>

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

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

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

@@ -1,12 +1,8 @@
 {% load helpers %}
 {% load helpers %}
 
 
 <div class="card">
 <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 %}
   {% if perms.extras.add_imageattachment %}
     <div class="card-footer text-end noprint">
     <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">
       <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):
 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'
     template_name = 'tenancy/object_contacts.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Contacts'),
         label=_('Contacts'),
         badge=lambda obj: obj.contacts.count(),
         badge=lambda obj: obj.contacts.count(),
-        permission='tenancy.view_contact',
+        permission='tenancy.view_contactassignment',
         weight=5000
         weight=5000
     )
     )
 
 
     def get_children(self, request, parent):
     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):
     def get_extra_context(self, request, instance):
         return {
         return {

+ 9 - 1
netbox/users/signals.py

@@ -1,10 +1,18 @@
 import logging
 import logging
 from django.dispatch import receiver
 from django.dispatch import receiver
 from django.contrib.auth.signals import user_login_failed
 from django.contrib.auth.signals import user_login_failed
+from utilities.request import get_client_ip
 
 
 
 
 @receiver(user_login_failed)
 @receiver(user_login_failed)
 def log_user_login_failed(sender, credentials, request, **kwargs):
 def log_user_login_failed(sender, credentials, request, **kwargs):
     logger = logging.getLogger('netbox.auth.login')
     logger = logging.getLogger('netbox.auth.login')
     username = credentials.get("username")
     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 import template
+from django.http import QueryDict
 
 
 __all__ = (
 __all__ = (
     'badge',
     'badge',
@@ -74,3 +75,22 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
         'true_label': true,
         'true_label': true,
         'false_label': false,
         '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:
     if unit == CableLengthUnitChoices.UNIT_FOOT:
         return length * Decimal(0.3048)
         return length * Decimal(0.3048)
     if unit == CableLengthUnitChoices.UNIT_INCH:
     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'.")
     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
 bleach==6.0.0
-boto3==1.26.138
+boto3==1.26.145
 Django==4.1.9
 Django==4.1.9
 django-cors-headers==4.0.0
 django-cors-headers==4.0.0
 django-debug-toolbar==4.1.0
 django-debug-toolbar==4.1.0
@@ -16,20 +16,20 @@ django-taggit==4.0.0
 django-timezone-field==5.0
 django-timezone-field==5.0
 djangorestframework==3.14.0
 djangorestframework==3.14.0
 drf-spectacular==0.26.2
 drf-spectacular==0.26.2
-drf-spectacular-sidecar==2023.5.1
+drf-spectacular-sidecar==2023.6.1
 dulwich==0.21.5
 dulwich==0.21.5
 feedparser==6.0.10
 feedparser==6.0.10
-graphene-django==3.0.2
+graphene-django==3.0.0
 gunicorn==20.1.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.3.7
 Markdown==3.3.7
-mkdocs-material==9.1.14
-mkdocstrings[python-legacy]==0.21.2
+mkdocs-material==9.1.15
+mkdocstrings[python-legacy]==0.22.0
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==9.5.0
 Pillow==9.5.0
 psycopg2-binary==2.9.6
 psycopg2-binary==2.9.6
 PyYAML==6.0
 PyYAML==6.0
-sentry-sdk==1.23.1
+sentry-sdk==1.25.0
 social-auth-app-django==5.2.0
 social-auth-app-django==5.2.0
 social-auth-core[openidconnect]==4.4.2
 social-auth-core[openidconnect]==4.4.2
 svgwrite==1.4.3
 svgwrite==1.4.3

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