diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 1a0e68929..5e456d0df 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.5.2
+ placeholder: v3.5.3
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 1f8fdebd4..e6a5e76c2 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -3,10 +3,13 @@ blank_issues_enabled: false
contact_links:
- name: 📖 Contributing Policy
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
- about: "Please read through our contributing policy before opening an issue or pull request"
+ about: "Please read through our contributing policy before opening an issue or pull request."
- name: ❓ Discussion
url: https://github.com/netbox-community/netbox/discussions
- about: "If you're just looking for help, try starting a discussion instead"
+ about: "If you're just looking for help, try starting a discussion instead."
+ - name: 💡 Plugin Idea
+ url: https://plugin-ideas.netbox.dev
+ about: "Have an idea for a plugin? Head over to the ideas board!"
- name: 💬 Community Slack
- url: https://netdev.chat/
- about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems"
+ url: https://netdev.chat
+ about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems."
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 526057454..e317dd64c 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.5.2
+ placeholder: v3.5.3
validations:
required: true
- type: dropdown
diff --git a/README.md b/README.md
index 81217797f..6e2b34fb8 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,4 @@
-
The :ballot_box_with_check: 2023 NetBox Community Survey is now open!
-
Please take a few minutes to tell us about your NetBox deployment.
-
The premiere source of truth powering network automation

diff --git a/base_requirements.txt b/base_requirements.txt
index 1e9a45048..40e0224e2 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -84,7 +84,8 @@ feedparser
# Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django/releases
-graphene_django
+# Pinned to v3.0.0 for GraphiQL UI issue (see #12762)
+graphene_django==3.0.0
# WSGI HTTP server
# https://docs.gunicorn.org/en/latest/news.html
diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md
index 0d92ec656..df0408f7c 100644
--- a/docs/models/extras/customfield.md
+++ b/docs/models/extras/customfield.md
@@ -68,11 +68,12 @@ Defines how filters are evaluated against custom field values.
Controls how and whether the custom field is displayed within the NetBox user interface.
-| Option | Description |
-|------------|--------------------------------------|
-| Read/write | Display and permit editing (default) |
-| Read-only | Display field but disallow editing |
-| Hidden | Do not display field in the UI |
+| Option | Description |
+|-------------------|--------------------------------------------------|
+| Read/write | Display and permit editing (default) |
+| Read-only | Display field but disallow editing |
+| Hidden | Do not display field in the UI |
+| Hidden (if unset) | Display in the UI only when a value has been set |
### Default
diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md
index 24d1ce2dc..3143bbdea 100644
--- a/docs/release-notes/version-3.5.md
+++ b/docs/release-notes/version-3.5.md
@@ -1,5 +1,34 @@
# NetBox v3.5
+## v3.5.3 (2023-06-02)
+
+### Enhancements
+
+* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules
+* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components
+* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration
+* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures
+* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset
+* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets
+
+### Bug Fixes
+
+* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API
+* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value
+* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object
+* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment
+* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables
+* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text
+* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form
+* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object
+* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view
+* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters
+* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version
+* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets
+* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters
+
+---
+
## v3.5.2 (2023-05-22)
### Enhancements
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index a62315b57..5b87c4e5d 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -1,12 +1,12 @@
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
-from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
-from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.routers import APIRootView
+from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.viewsets import ViewSet
from circuits.models import Circuit
@@ -14,7 +14,6 @@ from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
-from extras.api.nested_serializers import NestedConfigTemplateSerializer
from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -22,6 +21,7 @@ from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
from utilities.utils import count_related
@@ -386,7 +386,12 @@ class PlatformViewSet(NetBoxModelViewSet):
# Devices/modules
#
-class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
+class DeviceViewSet(
+ SequentialBulkCreatesMixin,
+ ConfigContextQuerySetMixin,
+ ConfigTemplateRenderMixin,
+ NetBoxModelViewSet
+):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index fccaa72f0..e784be8e8 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -1219,6 +1219,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label=_('Device (name)'),
)
+ device_type_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__device_type',
+ queryset=DeviceType.objects.all(),
+ label=_('Device type (ID)'),
+ )
+ device_type = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__device_type__model',
+ queryset=DeviceType.objects.all(),
+ to_field_name='model',
+ label=_('Device type (model)'),
+ )
+ device_role_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__device_role',
+ queryset=DeviceRole.objects.all(),
+ label=_('Device role (ID)'),
+ )
+ device_role = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__device_role__slug',
+ queryset=DeviceRole.objects.all(),
+ to_field_name='slug',
+ label=_('Device role (slug)'),
+ )
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis',
queryset=VirtualChassis.objects.all(),
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index d31bba030..4edee6014 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -102,13 +102,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Virtual Chassis')
)
+ device_type_id = DynamicModelMultipleChoiceField(
+ queryset=DeviceType.objects.all(),
+ required=False,
+ label=_('Device type')
+ )
+ device_role_id = DynamicModelMultipleChoiceField(
+ queryset=DeviceRole.objects.all(),
+ required=False,
+ label=_('Device role')
+ )
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
- 'virtual_chassis_id': '$virtual_chassis_id'
+ 'virtual_chassis_id': '$virtual_chassis_id',
+ 'device_type_id': '$device_type_id',
+ 'role_id': '$device_role_id'
},
label=_('Device')
)
@@ -1070,7 +1082,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+ ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1089,7 +1102,8 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+ ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1108,7 +1122,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+ ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1123,7 +1138,8 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+ ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1141,8 +1157,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
- 'device_id', 'vdc_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+ ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
vdc_id = DynamicModelMultipleChoiceField(
@@ -1242,7 +1258,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+ ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Cable', ('cabled', 'occupied')),
)
model = FrontPort
@@ -1261,7 +1278,8 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+ ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Cable', ('cabled', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1279,7 +1297,8 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'position')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+ ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
tag = TagFilterField(model)
position = forms.CharField(
@@ -1292,7 +1311,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+ ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
tag = TagFilterField(model)
@@ -1302,7 +1322,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
+ ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index d4c9e6ec3..9589ab533 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -101,6 +101,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
choices=[],
label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'),
+ widget=forms.SelectMultiple(attrs={'size': 6})
)
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index 3445b7e75..af15e1343 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -1115,7 +1115,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
- DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=2),
)
DeviceType.objects.bulk_create(device_types)
@@ -1229,6 +1229,39 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+ def test_rack_fit(self):
+ """
+ Check that creating multiple devices with overlapping position fails.
+ """
+ device = Device.objects.first()
+ device_type = DeviceType.objects.all()[1]
+ data = [
+ {
+ 'device_type': device_type.pk,
+ 'device_role': device.device_role.pk,
+ 'site': device.site.pk,
+ 'name': 'Test Device 7',
+ 'rack': device.rack.pk,
+ 'face': 'front',
+ 'position': 1
+ },
+ {
+ 'device_type': device_type.pk,
+ 'device_role': device.device_role.pk,
+ 'site': device.site.pk,
+ 'name': 'Test Device 8',
+ 'rack': device.rack.pk,
+ 'face': 'front',
+ 'position': 2
+ }
+ ]
+
+ self.add_permissions('dcim.add_device')
+ url = reverse('dcim-api:device-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index 346b35005..3f9712f2a 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -12,6 +12,23 @@ from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
+class DeviceComponentFilterSetTests:
+
+ def test_device_type(self):
+ device_types = DeviceType.objects.all()[:2]
+ params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'device_type': [device_types[0].model, device_types[1].model]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_device_role(self):
+ device_role = DeviceRole.objects.all()[:2]
+ params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'device_role': [device_role[0].slug, device_role[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Region.objects.all()
filterset = RegionFilterSet
@@ -1998,7 +2015,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePort.objects.all()
filterset = ConsolePortFilterSet
@@ -2027,10 +2044,23 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
+
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ device_types = (
+ DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
- device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2048,10 +2078,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
- Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
+ Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+ Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+ Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+ Device(name=None, device_type=device_types[0], device_role=device_roles[0], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2165,7 +2195,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsoleServerPort.objects.all()
filterset = ConsoleServerPortFilterSet
@@ -2194,10 +2224,23 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
+
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ device_types = (
+ DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
- device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2215,10 +2258,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
- Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
+ Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+ Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+ Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+ Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2332,7 +2375,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = PowerPort.objects.all()
filterset = PowerPortFilterSet
@@ -2361,10 +2404,23 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
+
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ device_types = (
+ DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
- device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2382,10 +2438,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
- Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
+ Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+ Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+ Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+ Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2507,7 +2563,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
+class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = PowerOutlet.objects.all()
filterset = PowerOutletFilterSet
@@ -2536,10 +2592,23 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
+
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ device_types = (
+ DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
- device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2557,10 +2626,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
- Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
+ Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+ Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+ Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+ Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2678,7 +2747,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
+class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = Interface.objects.all()
filterset = InterfaceFilterSet
@@ -2707,10 +2776,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
+
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ device_types = (
+ DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
- device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2728,10 +2810,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
- Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
+ Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+ Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+ Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+ Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -3101,7 +3183,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all()
filterset = FrontPortFilterSet
@@ -3130,10 +3212,23 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
+
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ device_types = (
+ DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
- device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3151,10 +3246,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
- Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
+ Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+ Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+ Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+ Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -3277,7 +3372,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
+class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = RearPort.objects.all()
filterset = RearPortFilterSet
@@ -3306,10 +3401,23 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
+
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ device_types = (
+ DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
- device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3327,10 +3435,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
- Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
+ Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+ Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+ Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
+ Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -3447,7 +3555,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
+class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ModuleBay.objects.all()
filterset = ModuleBayFilterSet
@@ -3476,9 +3584,21 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
+
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
- device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+ device_types = (
+ DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3496,9 +3616,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
+ Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+ Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+ Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
)
Device.objects.bulk_create(devices)
@@ -3564,7 +3684,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
+class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = DeviceBay.objects.all()
filterset = DeviceBayFilterSet
@@ -3593,9 +3713,21 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
+
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
- device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+ device_types = (
+ DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3613,9 +3745,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
+ Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+ Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+ Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
)
Device.objects.bulk_create(devices)
@@ -3694,8 +3826,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Manufacturer.objects.bulk_create(manufacturers)
- device_type = DeviceType.objects.create(manufacturer=manufacturers[0], model='Model 1', slug='model-1')
- device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+ device_types = (
+ DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1'),
+ DeviceRole(name='Device Role 2', slug='device-role-2'),
+ DeviceRole(name='Device Role 3', slug='device-role-3'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
regions = (
Region(name='Region 1', slug='region-1'),
@@ -3736,9 +3879,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
- Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
- Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
+ Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
+ Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
+ Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
)
Device.objects.bulk_create(devices)
@@ -3829,6 +3972,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ def test_device_type(self):
+ device_types = DeviceType.objects.all()[:2]
+ params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ params = {'device_type': [device_types[0].model, device_types[1].model]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_device_role(self):
+ device_role = DeviceRole.objects.all()[:2]
+ params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ params = {'device_role': [device_role[0].slug, device_role[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py
index e10516c4c..6fc14b965 100644
--- a/netbox/extras/choices.py
+++ b/netbox/extras/choices.py
@@ -56,11 +56,13 @@ class CustomFieldVisibilityChoices(ChoiceSet):
VISIBILITY_READ_WRITE = 'read-write'
VISIBILITY_READ_ONLY = 'read-only'
VISIBILITY_HIDDEN = 'hidden'
+ VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset'
CHOICES = (
(VISIBILITY_READ_WRITE, 'Read/Write'),
(VISIBILITY_READ_ONLY, 'Read-only'),
(VISIBILITY_HIDDEN, 'Hidden'),
+ (VISIBILITY_HIDDEN_IFUNSET, 'Hidden (if unset)'),
)
diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py
index c6744e524..db054149e 100644
--- a/netbox/extras/conditions.py
+++ b/netbox/extras/conditions.py
@@ -65,8 +65,14 @@ class Condition:
"""
Evaluate the provided data to determine whether it matches the condition.
"""
+ def _get(obj, key):
+ if isinstance(obj, list):
+ return [dict.get(i, key) for i in obj]
+
+ return dict.get(obj, key)
+
try:
- value = functools.reduce(dict.get, self.attr.split('.'), data)
+ value = functools.reduce(_get, self.attr.split('.'), data)
except TypeError:
# Invalid key path
value = None
diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py
index dc68e1388..b3a4d090c 100644
--- a/netbox/extras/dashboard/widgets.py
+++ b/netbox/extras/dashboard/widgets.py
@@ -10,8 +10,9 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db.models import Q
+from django.http import QueryDict
from django.template.loader import render_to_string
-from django.urls import NoReverseMatch, reverse
+from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _
from extras.utils import FeatureQuery
@@ -149,7 +150,7 @@ class ObjectCountsWidget(DashboardWidget):
filters = forms.JSONField(
required=False,
label='Object filters',
- help_text=_("Only objects matching the specified filters will be counted")
+ help_text=_("Filters to apply when counting the number of objects")
)
def clean_filters(self):
@@ -158,13 +159,6 @@ class ObjectCountsWidget(DashboardWidget):
dict(data)
except TypeError:
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
- for model in get_models_from_content_types(self.cleaned_data.get('models')):
- try:
- # Validate the filters by creating a QuerySet
- model.objects.filter(**data).none()
- except Exception:
- model_name = model._meta.verbose_name_plural
- raise forms.ValidationError(f"Invalid filter specification for {model_name}.")
return data
def render(self, request):
@@ -172,13 +166,19 @@ class ObjectCountsWidget(DashboardWidget):
for model in get_models_from_content_types(self.config['models']):
permission = get_permission_for_model(model, 'view')
if request.user.has_perm(permission):
+ url = reverse(get_viewname(model, 'list'))
qs = model.objects.restrict(request.user, 'view')
+ # Apply any specified filters
if filters := self.config.get('filters'):
- qs = qs.filter(**filters)
+ params = QueryDict(mutable=True)
+ params.update(filters)
+ filterset = getattr(resolve(url).func.view_class, 'filterset', None)
+ qs = filterset(params, qs).qs
+ url = f'{url}?{params.urlencode()}'
object_count = qs.count
- counts.append((model, object_count))
+ counts.append((model, object_count, url))
else:
- counts.append((model, None))
+ counts.append((model, None, None))
return render_to_string(self.template_name, {
'counts': counts,
diff --git a/netbox/extras/lookups.py b/netbox/extras/lookups.py
index 77fe2301e..a8d89c943 100644
--- a/netbox/extras/lookups.py
+++ b/netbox/extras/lookups.py
@@ -7,12 +7,14 @@ class Empty(Lookup):
Filter on whether a string is empty.
"""
lookup_name = 'empty'
+ prepare_rhs = False
- 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
+ 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):
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index 16e4fb577..9433ab6b0 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -274,10 +274,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
:param context: The context passed to Jinja2
"""
- text = render_jinja2(self.link_text, context)
+ text = render_jinja2(self.link_text, context).strip()
if not text:
return {}
- link = render_jinja2(self.link_url, context)
+ link = render_jinja2(self.link_url, context).strip()
link_target = ' target="_blank"' if self.new_window else ''
# Sanitize link text
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 8d046b85d..9e4924532 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -22,6 +22,14 @@ __all__ = (
'WebhookTable',
)
+IMAGEATTACHMENT_IMAGE = '''
+{% if record.image %}
+
{{ record }}
+{% else %}
+ —
+{% endif %}
+'''
+
class CustomFieldTable(NetBoxTable):
name = tables.Column(
@@ -96,6 +104,9 @@ class ImageAttachmentTable(NetBoxTable):
parent = tables.Column(
linkify=True
)
+ image = tables.TemplateColumn(
+ template_code=IMAGEATTACHMENT_IMAGE,
+ )
size = tables.Column(
orderable=False,
verbose_name='Size (bytes)'
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index ac75e2cc3..eb6dbe598 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -328,6 +328,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
):
self.initial['primary_for_parent'] = True
+ # Disable object assignment fields if the IP address is designated as primary
+ if self.initial.get('primary_for_parent'):
+ self.fields['interface'].disabled = True
+ self.fields['vminterface'].disabled = True
+ self.fields['fhrpgroup'].disabled = True
+
def clean(self):
super().clean()
@@ -340,7 +346,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
selected_objects[1]: "An IP address can only be assigned to a single object."
})
elif selected_objects:
- self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
+ assigned_object = self.cleaned_data[selected_objects[0]]
+ if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
+ raise ValidationError(
+ "Cannot reassign IP address while it is designated as the primary IP for the parent object"
+ )
+ self.instance.assigned_object = assigned_object
else:
self.instance.assigned_object = None
diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py
index 8b629bbc6..fde486fe9 100644
--- a/netbox/netbox/api/viewsets/mixins.py
+++ b/netbox/netbox/api/viewsets/mixins.py
@@ -15,11 +15,12 @@ from utilities.api import get_serializer_for_model
__all__ = (
'BriefModeMixin',
+ 'BulkDestroyModelMixin',
'BulkUpdateModelMixin',
'CustomFieldsMixin',
'ExportTemplatesMixin',
- 'BulkDestroyModelMixin',
'ObjectValidationMixin',
+ 'SequentialBulkCreatesMixin',
)
@@ -94,6 +95,30 @@ class ExportTemplatesMixin:
return super().list(request, *args, **kwargs)
+class SequentialBulkCreatesMixin:
+ """
+ Perform bulk creation of new objects sequentially, rather than all at once. This ensures that any validation
+ which depends on the evaluation of existing objects (such as checking for free space within a rack) functions
+ appropriately.
+ """
+ @transaction.atomic
+ def create(self, request, *args, **kwargs):
+ if not isinstance(request.data, list):
+ # Creating a single object
+ return super().create(request, *args, **kwargs)
+
+ return_data = []
+ for data in request.data:
+ serializer = self.get_serializer(data=data)
+ serializer.is_valid(raise_exception=True)
+ self.perform_create(serializer)
+ return_data.append(serializer.data)
+
+ headers = self.get_success_headers(serializer.data)
+
+ return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
+
+
class BulkUpdateModelMixin:
"""
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py
index a0c1edee8..9a2385c45 100644
--- a/netbox/netbox/filtersets.py
+++ b/netbox/netbox/filtersets.py
@@ -177,7 +177,8 @@ class BaseFilterSet(django_filters.FilterSet):
# create the new filter with the same type because there is no guarantee the defined type
# is the same as the default type for the field
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
- new_filter = type(existing_filter)(
+ filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter)
+ new_filter = filter_cls(
field_name=field_name,
lookup_expr=lookup_expr,
label=existing_filter.label,
@@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet):
return filters
+ @classmethod
+ def filter_for_lookup(cls, field, lookup_type):
+
+ if lookup_type == 'empty':
+ return django_filters.BooleanFilter, {}
+
+ return super().filter_for_lookup(field, lookup_type)
+
class ChangeLoggedModelFilterSet(BaseFilterSet):
"""
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index 6d82e2a2b..8bacba534 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -197,11 +197,15 @@ class CustomFieldsMixin(models.Model):
data = {}
for field in CustomField.objects.get_for_model(self):
- # Skip fields that are hidden if 'omit_hidden' is set
- if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
- continue
-
value = self.custom_field_data.get(field.name)
+
+ # Skip fields that are hidden if 'omit_hidden' is set
+ if omit_hidden:
+ if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
+ continue
+ if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value:
+ continue
+
data[field] = field.deserialize(value)
return data
@@ -227,6 +231,8 @@ class CustomFieldsMixin(models.Model):
for cf in visible_custom_fields:
value = self.custom_field_data.get(cf.name)
+ if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET:
+ continue
value = cf.deserialize(value)
groups[cf.group_name][cf] = value
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index a5923c9f0..4e27e59a4 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
-VERSION = '3.5.2'
+VERSION = '3.5.3'
# Hostname
HOSTNAME = platform.node()
diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py
index 66ee787a8..9ef327026 100644
--- a/netbox/netbox/tables/columns.py
+++ b/netbox/netbox/tables/columns.py
@@ -234,8 +234,12 @@ class ActionsColumn(tables.Column):
return ''
model = table.Meta.model
- request = getattr(table, 'context', {}).get('request')
- url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
+ if request := getattr(table, 'context', {}).get('request'):
+ return_url = request.GET.get('return_url', request.get_full_path())
+ url_appendix = f'?return_url={quote(return_url)}'
+ else:
+ url_appendix = ''
+
html = ''
# Compile actions menu
diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js
index 8caaaa9a0..9642d1585 100644
Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ
diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map
index 0201e7bf8..f86d50148 100644
Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ
diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json
index f10b5b7ac..98e1a5c60 100644
--- a/netbox/project-static/package.json
+++ b/netbox/project-static/package.json
@@ -30,6 +30,7 @@
"dayjs": "^1.11.5",
"flatpickr": "4.6.13",
"gridstack": "^7.2.3",
+ "html-entities": "^2.3.3",
"htmx.org": "^1.8.0",
"just-debounce-it": "^3.1.1",
"query-string": "^7.1.1",
diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts
index 09d423cbd..2410a5fd9 100644
--- a/netbox/project-static/src/htmx.ts
+++ b/netbox/project-static/src/htmx.ts
@@ -2,9 +2,10 @@ import { getElements, isTruthy } from './util';
import { initButtons } from './buttons';
import { initSelect } from './select';
import { initObjectSelector } from './objectSelector';
+import { initBootstrap } from './bs';
function initDepedencies(): void {
- for (const init of [initButtons, initSelect, initObjectSelector]) {
+ for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
init();
}
}
@@ -22,4 +23,8 @@ export function initHtmx(): void {
}
}
}
+
+ for (const element of getElements('[hx-trigger=load]')) {
+ element.addEventListener('htmx:afterSettle', initDepedencies);
+ }
}
diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts
index f5b605d58..53996910e 100644
--- a/netbox/project-static/src/select/api/apiSelect.ts
+++ b/netbox/project-static/src/select/api/apiSelect.ts
@@ -1,5 +1,6 @@
import { readableColor } from 'color2k';
import debounce from 'just-debounce-it';
+import { encode } from 'html-entities';
import queryString from 'query-string';
import SlimSelect from 'slim-select';
import { createToast } from '../../bs';
@@ -446,7 +447,7 @@ export class APISelect {
// Build SlimSelect options from all already-selected options.
const preSelectedOptions = preSelected.map(option => ({
value: option.value,
- text: option.innerText,
+ text: encode(option.innerText),
selected: true,
disabled: false,
})) as Option[];
@@ -454,7 +455,7 @@ export class APISelect {
let options = [] as Option[];
for (const result of data.results) {
- let text = result.display;
+ let text = encode(result.display);
if (typeof result._depth === 'number' && result._depth > 0) {
// If the object has a `_depth` property, indent its display text.
diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock
index c4bee7557..2adc50001 100644
--- a/netbox/project-static/yarn.lock
+++ b/netbox/project-static/yarn.lock
@@ -1818,6 +1818,11 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
+html-entities@^2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46"
+ integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==
+
htmx.org@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3"
diff --git a/netbox/templates/dcim/device/render_config.html b/netbox/templates/dcim/device/render_config.html
index b6e16701f..dfda7cdf6 100644
--- a/netbox/templates/dcim/device/render_config.html
+++ b/netbox/templates/dcim/device/render_config.html
@@ -28,8 +28,22 @@