diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 43ab47c9d..b0b8c02ad 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -13,7 +13,9 @@ body:
- type: dropdown
attributes:
label: Deployment Type
- description: How are you running NetBox?
+ description: >
+ How are you running NetBox? (For issues with the Docker image, please go to the
+ [netbox-docker](https://github.com/netbox-community/netbox-docker) repo.)
options:
- Self-hosted
- NetBox Cloud
@@ -23,7 +25,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
- placeholder: v3.7.2
+ placeholder: v3.7.3
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index a198fd731..bc99999c0 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.7.2
+ placeholder: v3.7.3
validations:
required: true
- type: dropdown
diff --git a/README.md b/README.md
index f166919c4..a81e41854 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
diff --git a/base_requirements.txt b/base_requirements.txt
index f6132d435..336607e5c 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -100,7 +100,7 @@ mkdocs-material
mkdocstrings[python-legacy]
# Library for manipulating IP prefixes and addresses
-# https://github.com/netaddr/netaddr/blob/master/CHANGELOG
+# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
netaddr
# Python bindings to the ammonia HTML sanitization library.
diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md
index fb789bd98..e7fe56a09 100644
--- a/docs/configuration/remote-authentication.md
+++ b/docs/configuration/remote-authentication.md
@@ -67,7 +67,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
Default: `|` (Pipe)
-The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+The Separator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md
index c9f05cd93..8b38d7c89 100644
--- a/docs/models/dcim/device.md
+++ b/docs/models/dcim/device.md
@@ -18,9 +18,9 @@ When a device has one or more interfaces with IP addresses assigned, a primary I
The device's configured name. This field is optional; devices can be unnamed. However, if set, the name must be unique to the assigned site and tenant.
-### Device Role
+### Role
-The functional [role](./devicerole.md) assigned to this device.
+The functional [device role](./devicerole.md) assigned to this device.
### Device Type
diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md
index 103b0664c..21e7489c3 100644
--- a/docs/release-notes/version-3.7.md
+++ b/docs/release-notes/version-3.7.md
@@ -1,6 +1,41 @@
# NetBox v3.7
-## v3.7.3 (FUTURE)
+## v3.7.4 (FUTURE)
+
+---
+
+## v3.7.3 (2024-02-21)
+
+### Enhancements
+
+* [#14587](https://github.com/netbox-community/netbox/issues/14587) - Display a human-friendly name for the OpenID Connect remote auth backend
+* [#14946](https://github.com/netbox-community/netbox/issues/14946) - Remove `associate_by_email()` from default social auth pipeline
+* [#14966](https://github.com/netbox-community/netbox/issues/14966) - Add PostgreSQL index for object type & ID on CachedValue table to improve performance
+* [#15177](https://github.com/netbox-community/netbox/issues/15177) - Add "last login" time to user display & REST API serializer
+
+### Bug Fixes
+
+* [#14058](https://github.com/netbox-community/netbox/issues/14058) - Limit platform options by manufacturer when editing a device or device type
+* [#14064](https://github.com/netbox-community/netbox/issues/14064) - Resolving parent location should consider assigned site when bulk importing locations
+* [#14079](https://github.com/netbox-community/netbox/issues/14079) - Ensure changes are logged on related objects when deleting an object referenced via a many-to-many relationship (e.g. tags)
+* [#14405](https://github.com/netbox-community/netbox/issues/14405) - Clean up formatting of link peers in bulk CSV export of cable termination objects
+* [#14689](https://github.com/netbox-community/netbox/issues/14689) - Preserve "empty" default values for JSON custom fields
+* [#14952](https://github.com/netbox-community/netbox/issues/14952) - Update existing AutoSyncRecord when changing the data file of an auto-synced object
+* [#15059](https://github.com/netbox-community/netbox/issues/15059) - Correct IP address count link in VM interfaces table
+* [#15067](https://github.com/netbox-community/netbox/issues/15067) - Fix uncaught exception when attempting invalid device bay import
+* [#15070](https://github.com/netbox-community/netbox/issues/15070) - Fix inclusion of `config_template` field on REST API serializer for virtual machines
+* [#15084](https://github.com/netbox-community/netbox/issues/15084) - Fix "add export template" link under "export" button on object list views
+* [#15090](https://github.com/netbox-community/netbox/issues/15090) - Ensure protection rules are evaluated prior to enqueueing events when deleting an object
+* [#15091](https://github.com/netbox-community/netbox/issues/15091) - Fix designation of the active tab for assigned object when modifying an L2VPN termination
+* [#15101](https://github.com/netbox-community/netbox/issues/15101) - Correct OpenAPI schema for rack elevation REST API endpoint
+* [#15115](https://github.com/netbox-community/netbox/issues/15115) - Fix unhandled exception with invalid permission constraints
+* [#15126](https://github.com/netbox-community/netbox/issues/15126) - `group` field should be optional when creating VPN tunnel via REST API
+* [#15127](https://github.com/netbox-community/netbox/issues/15127) - Add missing group column to VPN tunnels table
+* [#15133](https://github.com/netbox-community/netbox/issues/15133) - Fix FHRP group representation on assignments REST API endpoint using brief mode
+* [#15174](https://github.com/netbox-community/netbox/issues/15174) - Warn that permission constraints are not supported for reports or scripts
+* [#15184](https://github.com/netbox-community/netbox/issues/15184) - Correct REST API schema definition for `front_image` & `rear_image` on DeviceType
+* [#15185](https://github.com/netbox-community/netbox/issues/15185) - Ensure error messages pertaining to related objects are displayed on the bulk import form
+* [#15192](https://github.com/netbox-community/netbox/issues/15192) - Fix exception when viewing current config when no history is present
---
diff --git a/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md
index 9bf0a4db8..4bae93fa8 100644
--- a/docs/release-notes/version-4.0.md
+++ b/docs/release-notes/version-4.0.md
@@ -13,6 +13,10 @@
The NetBox user interface has been completely refreshed and updated.
+#### Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087))
+
+The REST API now supports specifying which fields to include in the response data.
+
### Enhancements
* [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
@@ -22,6 +26,9 @@ The NetBox user interface has been completely refreshed and updated.
* [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12
* [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI
* [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI
+* [#14438](https://github.com/netbox-community/netbox/issues/14438) - Track individual custom scripts as database objects
+* [#15131](https://github.com/netbox-community/netbox/issues/15131) - Automatically annotate related object counts on REST API querysets
+* [#15238](https://github.com/netbox-community/netbox/issues/15238) - Include the `description` field in "brief" REST API serializations
### Other Changes
@@ -34,5 +41,6 @@ The NetBox user interface has been completely refreshed and updated.
* [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin`
* [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`)
* [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class
+* [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features
* [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices
* [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class
diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py
index 4f88b9b28..574924c4a 100644
--- a/netbox/circuits/api/serializers.py
+++ b/netbox/circuits/api/serializers.py
@@ -4,8 +4,8 @@ from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.api.nested_serializers import NestedSiteSerializer
from dcim.api.serializers import CabledObjectSerializer
-from ipam.models import ASN
from ipam.api.nested_serializers import NestedASNSerializer
+from ipam.models import ASN
from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -40,6 +40,7 @@ class ProviderSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
#
@@ -56,6 +57,7 @@ class ProviderAccountSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
#
@@ -72,6 +74,7 @@ class ProviderNetworkSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
#
@@ -90,6 +93,7 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'circuit_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
@@ -122,6 +126,7 @@ class CircuitSerializer(NetBoxModelSerializer):
'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'cid', 'description')
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
@@ -137,3 +142,4 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
+ brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')
diff --git a/netbox/circuits/apps.py b/netbox/circuits/apps.py
index 3acf3b98c..df6804303 100644
--- a/netbox/circuits/apps.py
+++ b/netbox/circuits/apps.py
@@ -6,4 +6,8 @@ class CircuitsConfig(AppConfig):
verbose_name = "Circuits"
def ready(self):
+ from netbox.models.features import register_models
from . import signals, search
+
+ # Register models
+ register_models(*self.get_models())
diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py
index 4dc775364..7b65d52ad 100644
--- a/netbox/circuits/models/circuits.py
+++ b/netbox/circuits/models/circuits.py
@@ -234,9 +234,9 @@ class CircuitTermination(
# Must define either site *or* provider network
if self.site is None and self.provider_network is None:
- raise ValidationError("A circuit termination must attach to either a site or a provider network.")
+ raise ValidationError(_("A circuit termination must attach to either a site or a provider network."))
if self.site and self.provider_network:
- raise ValidationError("A circuit termination cannot attach to both a site and a provider network.")
+ raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network."))
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py
index 1969441eb..c8ec08943 100644
--- a/netbox/circuits/tests/test_api.py
+++ b/netbox/circuits/tests/test_api.py
@@ -18,7 +18,7 @@ class AppTest(APITestCase):
class ProviderTest(APIViewTestCases.APIViewTestCase):
model = Provider
- brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
+ brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
bulk_update_data = {
'comments': 'New comments',
}
@@ -60,7 +60,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
model = CircuitType
- brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
+ brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
create_data = (
{
'name': 'Circuit Type 4',
@@ -92,7 +92,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
class CircuitTest(APIViewTestCases.APIViewTestCase):
model = Circuit
- brief_fields = ['cid', 'display', 'id', 'url']
+ brief_fields = ['cid', 'description', 'display', 'id', 'url']
bulk_update_data = {
'status': 'planned',
}
@@ -149,7 +149,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
model = CircuitTermination
- brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url']
+ brief_fields = ['_occupied', 'cable', 'circuit', 'description', 'display', 'id', 'term_side', 'url']
@classmethod
def setUpTestData(cls):
@@ -208,7 +208,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
model = ProviderAccount
- brief_fields = ['account', 'display', 'id', 'name', 'url']
+ brief_fields = ['account', 'description', 'display', 'id', 'name', 'url']
@classmethod
def setUpTestData(cls):
@@ -251,7 +251,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
model = ProviderNetwork
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
@classmethod
def setUpTestData(cls):
diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py
index b7e537c23..8eecfa8b9 100644
--- a/netbox/core/api/schema.py
+++ b/netbox/core/api/schema.py
@@ -8,6 +8,7 @@ from drf_spectacular.plumbing import (
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
)
from drf_spectacular.types import OpenApiTypes
+from rest_framework import serializers
from rest_framework.relations import ManyRelatedField
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py
index d685ba43d..be3e9ff4a 100644
--- a/netbox/core/api/serializers.py
+++ b/netbox/core/api/serializers.py
@@ -36,6 +36,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
class DataFileSerializer(NetBoxModelSerializer):
@@ -51,6 +52,7 @@ class DataFileSerializer(NetBoxModelSerializer):
fields = [
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
]
+ brief_fields = ('id', 'url', 'display', 'path')
class JobSerializer(BaseModelSerializer):
@@ -69,3 +71,4 @@ class JobSerializer(BaseModelSerializer):
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
'started', 'completed', 'user', 'data', 'error', 'job_id',
]
+ brief_fields = ('url', 'created', 'completed', 'user', 'status')
diff --git a/netbox/core/apps.py b/netbox/core/apps.py
index 2d999c57e..b1103469c 100644
--- a/netbox/core/apps.py
+++ b/netbox/core/apps.py
@@ -16,5 +16,9 @@ class CoreConfig(AppConfig):
name = "core"
def ready(self):
+ from core.api import schema # noqa
+ from netbox.models.features import register_models
from . import data_backends, search
- from core.api import schema # noqa: E402
+
+ # Register models
+ register_models(*self.get_models())
diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py
index 9ff0b4d63..15891a6f5 100644
--- a/netbox/core/data_backends.py
+++ b/netbox/core/data_backends.py
@@ -102,7 +102,7 @@ class GitBackend(DataBackend):
try:
porcelain.clone(self.url, local_path.name, **clone_args)
except BaseException as e:
- raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")
+ raise SyncError(_("Fetching remote data failed ({name}): {error}").format(name=type(e).__name__, error=e))
yield local_path.name
diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py
index 52fa3a608..e0c71fe48 100644
--- a/netbox/core/forms/model_forms.py
+++ b/netbox/core/forms/model_forms.py
@@ -103,9 +103,9 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
super().clean()
if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'):
- raise forms.ValidationError("Cannot upload a file and sync from an existing file")
+ raise forms.ValidationError(_("Cannot upload a file and sync from an existing file"))
if not self.cleaned_data.get('upload_file') and not self.cleaned_data.get('data_file'):
- raise forms.ValidationError("Must upload a file or select a data file to sync")
+ raise forms.ValidationError(_("Must upload a file or select a data file to sync"))
return self.cleaned_data
diff --git a/netbox/core/models/config.py b/netbox/core/models/config.py
index 6c8e41477..b2381ae40 100644
--- a/netbox/core/models/config.py
+++ b/netbox/core/models/config.py
@@ -44,7 +44,7 @@ class ConfigRevision(models.Model):
return gettext('Config revision #{id}').format(id=self.pk)
def __getattr__(self, item):
- if item in self.data:
+ if self.data and item in self.data:
return self.data[item]
return super().__getattribute__(item)
diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py
index 6597a4b4d..4ceb22ba9 100644
--- a/netbox/core/models/data.py
+++ b/netbox/core/models/data.py
@@ -177,7 +177,7 @@ class DataSource(JobsMixin, PrimaryModel):
Create/update/delete child DataFiles as necessary to synchronize with the remote source.
"""
if self.status == DataSourceStatusChoices.SYNCING:
- raise SyncError("Cannot initiate sync; syncing already in progress.")
+ raise SyncError(_("Cannot initiate sync; syncing already in progress."))
# Emit the pre_sync signal
pre_sync.send(sender=self.__class__, instance=self)
@@ -190,7 +190,7 @@ class DataSource(JobsMixin, PrimaryModel):
backend = self.get_backend()
except ModuleNotFoundError as e:
raise SyncError(
- f"There was an error initializing the backend. A dependency needs to be installed: {e}"
+ _("There was an error initializing the backend. A dependency needs to be installed: ") + str(e)
)
with backend.fetch() as local_path:
diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py
index 7cc62a15a..2e3425129 100644
--- a/netbox/core/models/jobs.py
+++ b/netbox/core/models/jobs.py
@@ -181,7 +181,11 @@ class Job(models.Model):
"""
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
if status not in valid_statuses:
- raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}")
+ raise ValueError(
+ _("Invalid status for job termination. Choices are: {choices}").format(
+ choices=', '.join(valid_statuses)
+ )
+ )
# Mark the job as completed
self.status = status
diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py
index cd25761f0..44db21bff 100644
--- a/netbox/core/tests/test_api.py
+++ b/netbox/core/tests/test_api.py
@@ -16,7 +16,7 @@ class AppTest(APITestCase):
class DataSourceTest(APIViewTestCases.APIViewTestCase):
model = DataSource
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'enabled': False,
'description': 'foo bar baz',
diff --git a/netbox/core/views.py b/netbox/core/views.py
index 5662b126e..0379900e1 100644
--- a/netbox/core/views.py
+++ b/netbox/core/views.py
@@ -184,7 +184,7 @@ class ConfigView(generic.ObjectView):
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
return ConfigRevision(
- data=get_config()
+ data=get_config().defaults
)
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index e4c51a476..8fbe9fd04 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -114,6 +114,7 @@ class RegionSerializer(NestedGroupModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'site_count', '_depth',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
class SiteGroupSerializer(NestedGroupModelSerializer):
@@ -127,6 +128,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'site_count', '_depth',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
class SiteSerializer(NetBoxModelSerializer):
@@ -159,6 +161,7 @@ class SiteSerializer(NetBoxModelSerializer):
'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
'virtualmachine_count', 'vlan_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
#
@@ -180,6 +183,7 @@ class LocationSerializer(NestedGroupModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
class RackRoleSerializer(NetBoxModelSerializer):
@@ -194,6 +198,7 @@ class RackRoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'rack_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
class RackSerializer(NetBoxModelSerializer):
@@ -222,6 +227,7 @@ class RackSerializer(NetBoxModelSerializer):
'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
class RackUnitSerializer(serializers.Serializer):
@@ -256,6 +262,7 @@ class RackReservationSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
'comments', 'tags', 'custom_fields',
]
+ brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
class RackElevationDetailFilterSerializer(serializers.Serializer):
@@ -315,6 +322,7 @@ class ManufacturerSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'devicetype_count', 'inventoryitem_count', 'platform_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
class DeviceTypeSerializer(NetBoxModelSerializer):
@@ -331,6 +339,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
+ front_image = serializers.URLField(allow_null=True, required=False)
+ rear_image = serializers.URLField(allow_null=True, required=False)
# Counter fields
console_port_template_count = serializers.IntegerField(read_only=True)
@@ -358,6 +368,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count',
]
+ brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
class ModuleTypeSerializer(NetBoxModelSerializer):
@@ -371,6 +382,7 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
#
@@ -401,6 +413,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@@ -427,6 +440,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
class PowerPortTemplateSerializer(ValidatedModelSerializer):
@@ -454,6 +468,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
'allocated_draw', 'description', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
@@ -491,6 +506,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
'description', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
class InterfaceTemplateSerializer(ValidatedModelSerializer):
@@ -535,6 +551,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
class RearPortTemplateSerializer(ValidatedModelSerializer):
@@ -557,6 +574,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
'description', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
class FrontPortTemplateSerializer(ValidatedModelSerializer):
@@ -580,6 +598,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
class ModuleBayTemplateSerializer(ValidatedModelSerializer):
@@ -592,6 +611,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
@@ -601,6 +621,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = DeviceBayTemplate
fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
class InventoryItemTemplateSerializer(ValidatedModelSerializer):
@@ -627,6 +648,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_component(self, obj):
@@ -655,6 +677,7 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
class PlatformSerializer(NetBoxModelSerializer):
@@ -672,13 +695,13 @@ class PlatformSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer()
role = NestedDeviceRoleSerializer()
- device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.')
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer()
@@ -720,14 +743,15 @@ class DeviceSerializer(NetBoxModelSerializer):
class Meta:
model = Device
fields = [
- 'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
- 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
- 'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
- 'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
- 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
- 'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
- 'device_bay_count', 'module_bay_count', 'inventory_item_count',
+ 'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
+ 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
+ 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
+ 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
+ 'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
+ 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
+ 'module_bay_count', 'inventory_item_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
@extend_schema_field(NestedDeviceSerializer)
def get_parent_device(self, obj):
@@ -740,22 +764,19 @@ class DeviceSerializer(NetBoxModelSerializer):
data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
return data
- def get_device_role(self, obj):
- return obj.role
-
class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True)
class Meta(DeviceSerializer.Meta):
fields = [
- 'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
- 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
- 'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
- 'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
- 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
- 'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
- 'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
+ 'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
+ 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
+ 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
+ 'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags',
+ 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
+ 'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
+ 'device_bay_count', 'module_bay_count', 'inventory_item_count',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
@@ -782,6 +803,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'interface_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
class ModuleSerializer(NetBoxModelSerializer):
@@ -797,6 +819,7 @@ class ModuleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
#
@@ -829,6 +852,7 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
+ brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -857,6 +881,7 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
+ brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -891,6 +916,7 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
+ brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -915,6 +941,7 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
+ brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -977,6 +1004,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
+ brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
def validate(self, data):
@@ -1008,6 +1036,7 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
+ brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class FrontPortRearPortSerializer(WritableNestedSerializer):
@@ -1038,6 +1067,7 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
+ brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ModuleBaySerializer(NetBoxModelSerializer):
@@ -1049,9 +1079,9 @@ class ModuleBaySerializer(NetBoxModelSerializer):
model = ModuleBay
fields = [
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
- 'custom_fields',
- 'created', 'last_updated',
+ 'custom_fields', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
class DeviceBaySerializer(NetBoxModelSerializer):
@@ -1065,6 +1095,7 @@ class DeviceBaySerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
'custom_fields', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
class InventoryItemSerializer(NetBoxModelSerializer):
@@ -1088,6 +1119,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
'custom_fields', 'created', 'last_updated', '_depth',
]
+ brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_component(self, obj):
@@ -1114,6 +1146,7 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'inventoryitem_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')
#
@@ -1134,6 +1167,7 @@ class CableSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'label', 'description')
class TracedCableSerializer(serializers.ModelSerializer):
@@ -1204,6 +1238,7 @@ class VirtualChassisSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'member_count',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')
#
@@ -1228,6 +1263,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
'powerfeed_count', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -1267,3 +1303,4 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index ea1a2e3d1..8a674656d 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -173,6 +173,12 @@ class RackViewSet(NetBoxModelViewSet):
serializer_class = serializers.RackSerializer
filterset_class = filtersets.RackFilterSet
+ @extend_schema(
+ operation_id='dcim_racks_elevation_retrieve',
+ filters=False,
+ parameters=[serializers.RackElevationDetailFilterSerializer],
+ responses={200: serializers.RackUnitSerializer(many=True)}
+ )
@action(detail=True)
def elevation(self, request, pk=None):
"""
@@ -372,12 +378,8 @@ class DeviceViewSet(
Else, return the DeviceWithConfigContextSerializer
"""
-
request = self.get_serializer_context()['request']
- if request.query_params.get('brief', False):
- return serializers.NestedDeviceSerializer
-
- elif 'config_context' in request.query_params.get('exclude', []):
+ if self.brief or 'config_context' in request.query_params.get('exclude', []):
return serializers.DeviceSerializer
return serializers.DeviceWithConfigContextSerializer
diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py
index 78ff0d4c1..4df66e367 100644
--- a/netbox/dcim/apps.py
+++ b/netbox/dcim/apps.py
@@ -8,9 +8,13 @@ class DCIMConfig(AppConfig):
verbose_name = "DCIM"
def ready(self):
+ from netbox.models.features import register_models
+ from utilities.counters import connect_counters
from . import signals, search
from .models import CableTermination, Device, DeviceType, VirtualChassis
- from utilities.counters import connect_counters
+
+ # Register models
+ register_models(*self.get_models())
# Register denormalized fields
denormalized.register(CableTermination, '_device', {
diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py
index db1a28d39..535756bac 100644
--- a/netbox/dcim/fields.py
+++ b/netbox/dcim/fields.py
@@ -1,6 +1,7 @@
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.db import models
+from django.utils.translation import gettext as _
from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
from .lookups import PathContains
@@ -41,7 +42,7 @@ class MACAddressField(models.Field):
try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
except AddrFormatError:
- raise ValidationError(f"Invalid MAC address format: {value}")
+ raise ValidationError(_("Invalid MAC address format: {value}").format(value=value))
def db_type(self, connection):
return 'macaddr'
@@ -67,7 +68,7 @@ class WWNField(models.Field):
try:
return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
except AddrFormatError:
- raise ValidationError(f"Invalid WWN format: {value}")
+ raise ValidationError(_("Invalid WWN format: {value}").format(value=value))
def db_type(self, connection):
return 'macaddr8'
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 4810e9cab..6b1611694 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -2,6 +2,8 @@ import django_filters
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
from circuits.models import CircuitTermination
from extras.filtersets import LocalConfigContextFilterSet
@@ -818,6 +820,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label=_('Manufacturer (slug)'),
)
+ available_for_device_type = django_filters.ModelChoiceFilter(
+ queryset=DeviceType.objects.all(),
+ method='get_for_device_type'
+ )
config_template_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigTemplate.objects.all(),
label=_('Config template (ID)'),
@@ -827,6 +833,14 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
model = Platform
fields = ['id', 'name', 'slug', 'description']
+ @extend_schema_field(OpenApiTypes.STR)
+ def get_for_device_type(self, queryset, name, value):
+ """
+ Return all Platforms available for a specific manufacturer based on device type and Platforms not assigned any
+ manufacturer
+ """
+ return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
+
class DeviceFilterSet(
NetBoxModelFilterSet,
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index f30ff91fa..47974096f 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -159,6 +159,14 @@ class LocationImportForm(NetBoxModelImportForm):
model = Location
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
+ def __init__(self, data=None, *args, **kwargs):
+ super().__init__(data, *args, **kwargs)
+
+ if data:
+ # Limit location queryset by assigned site
+ params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+ self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
+
class RackRoleImportForm(NetBoxModelImportForm):
slug = SlugField()
@@ -870,7 +878,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
def clean_vdcs(self):
for vdc in self.cleaned_data['vdcs']:
if vdc.device != self.cleaned_data['device']:
- raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
+ raise forms.ValidationError(
+ _("VDC {vdc} is not assigned to device {device}").format(
+ vdc=vdc, device=self.cleaned_data['device']
+ )
+ )
return self.cleaned_data['vdcs']
@@ -996,7 +1008,7 @@ class DeviceBayImportForm(NetBoxModelImportForm):
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
).exclude(pk=device.pk)
else:
- self.fields['installed_device'].queryset = Interface.objects.none()
+ self.fields['installed_device'].queryset = Device.objects.none()
class InventoryItemImportForm(NetBoxModelImportForm):
@@ -1075,7 +1087,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
component = model.objects.get(device=device, name=component_name)
self.instance.component = component
except ObjectDoesNotExist:
- raise forms.ValidationError(f"Component not found: {device} - {component_name}")
+ raise forms.ValidationError(
+ _("Component not found: {device} - {component_name}").format(
+ device=device, component_name=component_name
+ )
+ )
#
@@ -1193,10 +1209,17 @@ class CableImportForm(NetBoxModelImportForm):
else:
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None and termination_object.cable != self.instance:
- raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
+ raise forms.ValidationError(
+ _("Side {side_upper}: {device} {termination_object} is already connected").format(
+ side_upper=side.upper(), device=device, termination_object=termination_object
+ )
+ )
except ObjectDoesNotExist:
- raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
-
+ raise forms.ValidationError(
+ _("{side_upper} side termination not found: {device} {name}").format(
+ side_upper=side.upper(), device=device, name=name
+ )
+ )
setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index a46136775..6c33ea8d6 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -291,7 +291,11 @@ class DeviceTypeForm(NetBoxModelForm):
default_platform = DynamicModelChoiceField(
label=_('Default platform'),
queryset=Platform.objects.all(),
- required=False
+ required=False,
+ selector=True,
+ query_params={
+ 'manufacturer_id': ['$manufacturer', 'null'],
+ }
)
slug = SlugField(
label=_('Slug'),
@@ -447,7 +451,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=_('Platform'),
queryset=Platform.objects.all(),
required=False,
- selector=True
+ selector=True,
+ query_params={
+ 'available_for_device_type': '$device_type',
+ }
)
cluster = DynamicModelChoiceField(
label=_('Cluster'),
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index 7ae755602..cba345941 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -160,25 +160,26 @@ class Cable(PrimaryModel):
# Validate length and length_unit
if self.length is not None and not self.length_unit:
- raise ValidationError("Must specify a unit when setting a cable length")
+ raise ValidationError(_("Must specify a unit when setting a cable length"))
if self.pk is None and (not self.a_terminations or not self.b_terminations):
- raise ValidationError("Must define A and B terminations when creating a new cable.")
+ raise ValidationError(_("Must define A and B terminations when creating a new cable."))
if self._terminations_modified:
# Check that all termination objects for either end are of the same type
for terms in (self.a_terminations, self.b_terminations):
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
- raise ValidationError("Cannot connect different termination types to same end of cable.")
+ raise ValidationError(_("Cannot connect different termination types to same end of cable."))
# Check that termination types are compatible
if self.a_terminations and self.b_terminations:
a_type = self.a_terminations[0]._meta.model_name
b_type = self.b_terminations[0]._meta.model_name
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
- raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
-
+ raise ValidationError(
+ _("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type)
+ )
if a_type == b_type:
# can't directly use self.a_terminations here as possible they
# don't have pk yet
@@ -327,17 +328,24 @@ class CableTermination(ChangeLoggedModel):
existing_termination = qs.first()
if existing_termination is not None:
raise ValidationError(
- f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
- f"{self.termination_id}: cable {existing_termination.cable.pk}"
+ _("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}".format(
+ app_label=self.termination_type.app_label,
+ model=self.termination_type.model,
+ termination_id=self.termination_id,
+ cable_pk=existing_termination.cable.pk
+ ))
)
-
# Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
- raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
+ raise ValidationError(
+ _("Cables cannot be terminated to {type_display} interfaces").format(
+ type_display=self.termination.get_type_display()
+ )
+ )
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
- raise ValidationError("Circuit terminations attached to a provider network may not be cabled.")
+ raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
def save(self, *args, **kwargs):
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 88dddb312..5b2564b32 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
super().clean()
# Validate that the parent Device can have DeviceBays
- if not self.device.device_type.is_parent_device:
+ if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
device_type=self.device.device_type
))
# Cannot install a device into itself, obviously
- if self.device == self.installed_device:
+ if self.installed_device and getattr(self, 'device', None) == self.installed_device:
raise ValidationError(_("Cannot install a device into itself."))
# Check that the installed device is not already installed elsewhere
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index b0edf49fe..c75757fa7 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -815,20 +815,6 @@ class Device(
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
- @property
- def device_role(self):
- """
- For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
- """
- return self.role
-
- @device_role.setter
- def device_role(self, value):
- """
- For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
- """
- self.role = value
-
def clean(self):
super().clean()
@@ -875,7 +861,7 @@ class Device(
if self.position and self.device_type.u_height == 0:
raise ValidationError({
'position': _(
- "A U0 device type ({device_type}) cannot be assigned to a rack position."
+ "A 0U device type ({device_type}) cannot be assigned to a rack position."
).format(device_type=self.device_type)
})
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 4c863e12a..d4c9641b6 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -359,6 +359,11 @@ class CableTerminationTable(NetBoxTable):
verbose_name=_('Mark Connected'),
)
+ def value_link_peer(self, value):
+ return ', '.join([
+ f"{termination.parent_object} > {termination}" for termination in value
+ ])
+
class PathEndpointTable(CableTerminationTable):
connection = columns.TemplateColumn(
diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py
index 835a72bc6..d3134656c 100644
--- a/netbox/dcim/tables/template_code.py
+++ b/netbox/dcim/tables/template_code.py
@@ -36,7 +36,7 @@ DEVICEBAY_STATUS = """
INTERFACE_IPADDRESSES = """
{% if value.count > 3 %}
- {{ value.count }}
+ {{ value.count }}
{% else %}
{% for ip in value.all %}
{% if ip.status != 'active' %}
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index d012a840f..0a3931696 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -1,6 +1,7 @@
from django.contrib.auth import get_user_model
from django.test import override_settings
from django.urls import reverse
+from django.utils.translation import gettext as _
from rest_framework import status
from dcim.choices import *
@@ -45,7 +46,7 @@ class Mixins:
name='Peer Device'
)
if self.peer_termination_type is None:
- raise NotImplementedError("Test case must set peer_termination_type")
+ raise NotImplementedError(_("Test case must set peer_termination_type"))
peer_obj = self.peer_termination_type.objects.create(
device=peer_device,
name='Peer Termination'
@@ -67,7 +68,7 @@ class Mixins:
class RegionTest(APIViewTestCases.APIViewTestCase):
model = Region
- brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url']
+ brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
create_data = [
{
'name': 'Region 4',
@@ -96,7 +97,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
class SiteGroupTest(APIViewTestCases.APIViewTestCase):
model = SiteGroup
- brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url']
+ brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
create_data = [
{
'name': 'Site Group 4',
@@ -125,7 +126,7 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase):
class SiteTest(APIViewTestCases.APIViewTestCase):
model = Site
- brief_fields = ['display', 'id', 'name', 'slug', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
bulk_update_data = {
'status': 'planned',
}
@@ -187,7 +188,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
class LocationTest(APIViewTestCases.APIViewTestCase):
model = Location
- brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
+ brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -237,7 +238,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
class RackRoleTest(APIViewTestCases.APIViewTestCase):
model = RackRole
- brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
create_data = [
{
'name': 'Rack Role 4',
@@ -272,7 +273,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
class RackTest(APIViewTestCases.APIViewTestCase):
model = Rack
- brief_fields = ['device_count', 'display', 'id', 'name', 'url']
+ brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url']
bulk_update_data = {
'status': 'planned',
}
@@ -360,7 +361,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
class RackReservationTest(APIViewTestCases.APIViewTestCase):
model = RackReservation
- brief_fields = ['display', 'id', 'units', 'url', 'user']
+ brief_fields = ['description', 'display', 'id', 'units', 'url', 'user']
bulk_update_data = {
'description': 'New description',
}
@@ -407,7 +408,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
model = Manufacturer
- brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url']
+ brief_fields = ['description', 'devicetype_count', 'display', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Manufacturer 4',
@@ -439,7 +440,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
model = DeviceType
- brief_fields = ['device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
+ brief_fields = ['description', 'device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
bulk_update_data = {
'part_number': 'ABC123',
}
@@ -484,7 +485,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
model = ModuleType
- brief_fields = ['display', 'id', 'manufacturer', 'model', 'url']
+ brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url']
bulk_update_data = {
'part_number': 'ABC123',
}
@@ -523,7 +524,7 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConsolePortTemplate
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -567,7 +568,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConsoleServerPortTemplate
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -611,7 +612,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = PowerPortTemplate
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -655,7 +656,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
model = PowerOutletTemplate
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -712,7 +713,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
model = InterfaceTemplate
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -760,7 +761,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = FrontPortTemplate
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -849,7 +850,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = RearPortTemplate
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -897,7 +898,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = ModuleBayTemplate
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -937,7 +938,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = DeviceBayTemplate
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -977,7 +978,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
model = InventoryItemTemplate
- brief_fields = ['_depth', 'display', 'id', 'name', 'url']
+ brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1028,7 +1029,7 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
model = DeviceRole
- brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
+ brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
create_data = [
{
'name': 'Device Role 4',
@@ -1063,7 +1064,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
class PlatformTest(APIViewTestCases.APIViewTestCase):
model = Platform
- brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
+ brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
create_data = [
{
'name': 'Platform 4',
@@ -1095,7 +1096,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
class DeviceTest(APIViewTestCases.APIViewTestCase):
model = Device
- brief_fields = ['display', 'id', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'status': 'failed',
}
@@ -1285,7 +1286,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module
- brief_fields = ['device', 'display', 'id', 'module_bay', 'module_type', 'url']
+ brief_fields = ['description', 'device', 'display', 'id', 'module_bay', 'module_type', 'url']
bulk_update_data = {
'serial': '1234ABCD',
}
@@ -1349,7 +1350,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsolePort
- brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+ brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1391,7 +1392,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsoleServerPort
- brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+ brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1433,7 +1434,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerPort
- brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+ brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1472,7 +1473,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerOutlet
- brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+ brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1520,7 +1521,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = Interface
- brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+ brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1654,7 +1655,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
class FrontPortTest(APIViewTestCases.APIViewTestCase):
model = FrontPort
- brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+ brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1712,7 +1713,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
class RearPortTest(APIViewTestCases.APIViewTestCase):
model = RearPort
- brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+ brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1754,7 +1755,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
model = ModuleBay
- brief_fields = ['display', 'id', 'module', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'installed_module', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1793,7 +1794,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay
- brief_fields = ['device', 'display', 'id', 'name', 'url']
+ brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1856,7 +1857,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
class InventoryItemTest(APIViewTestCases.APIViewTestCase):
model = InventoryItem
- brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url']
+ brief_fields = ['_depth', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1916,7 +1917,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
model = InventoryItemRole
- brief_fields = ['display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
+ brief_fields = ['description', 'display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
create_data = [
{
'name': 'Inventory Item Role 4',
@@ -1951,7 +1952,7 @@ class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
class CableTest(APIViewTestCases.APIViewTestCase):
model = Cable
- brief_fields = ['display', 'id', 'label', 'url']
+ brief_fields = ['description', 'display', 'id', 'label', 'url']
bulk_update_data = {
'length': 100,
'length_unit': 'm',
@@ -2074,7 +2075,7 @@ class ConnectedDeviceTest(APITestCase):
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
model = VirtualChassis
- brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url']
+ brief_fields = ['description', 'display', 'id', 'master', 'member_count', 'name', 'url']
@classmethod
def setUpTestData(cls):
@@ -2155,7 +2156,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
class PowerPanelTest(APIViewTestCases.APIViewTestCase):
model = PowerPanel
- brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url']
+ brief_fields = ['description', 'display', 'id', 'name', 'powerfeed_count', 'url']
@classmethod
def setUpTestData(cls):
@@ -2204,7 +2205,7 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
class PowerFeedTest(APIViewTestCases.APIViewTestCase):
model = PowerFeed
- brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url']
+ brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url']
bulk_update_data = {
'status': 'planned',
}
@@ -2259,7 +2260,7 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
model = VirtualDeviceContext
- brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url']
+ brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url']
bulk_update_data = {
'status': 'planned',
}
diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py
index a827939f7..49a71022e 100644
--- a/netbox/dcim/tests/test_cablepaths.py
+++ b/netbox/dcim/tests/test_cablepaths.py
@@ -2156,7 +2156,7 @@ class CablePathTestCase(TestCase):
device = Device.objects.create(
site=self.site,
device_type=self.device.device_type,
- device_role=self.device.device_role,
+ role=self.device.role,
name='Test mid-span Device'
)
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index 89d15a0ef..b255c283e 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -1787,6 +1787,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='foobar1'),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='foobar2'),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
+ Platform(name='Platform 4', slug='platform-4'),
)
Platform.objects.bulk_create(platforms)
@@ -1813,6 +1814,17 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_available_for_device_type(self):
+ manufacturers = Manufacturer.objects.all()[:2]
+ device_type = DeviceType.objects.create(
+ manufacturer=manufacturers[0],
+ model='Device Type 1',
+ slug='device-type-1',
+ u_height=1
+ )
+ params = {'available_for_device_type': device_type.pk}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Device.objects.all()
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index d56bf0741..8eb057020 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/netbox/dcim/tests/test_models.py
@@ -533,30 +533,6 @@ class DeviceTestCase(TestCase):
device2.full_clean()
device2.save()
- def test_old_device_role_field(self):
- """
- Ensure that the old device role field sets the value in the new role field.
- """
-
- # Test getter method
- device = Device(
- site=Site.objects.first(),
- device_type=DeviceType.objects.first(),
- role=DeviceRole.objects.first(),
- name='Test Device 1',
- device_role=DeviceRole.objects.first()
- )
- device.full_clean()
- device.save()
-
- self.assertEqual(device.role, device.device_role)
-
- # Test setter method
- device.device_role = DeviceRole.objects.last()
- device.full_clean()
- device.save()
- self.assertEqual(device.role, device.device_role)
-
class CableTestCase(TestCase):
diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py
index ce3b388f4..6cd3a245e 100644
--- a/netbox/extras/api/customfields.py
+++ b/netbox/extras/api/customfields.py
@@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from rest_framework.fields import Field
@@ -88,7 +89,7 @@ class CustomFieldsDataField(Field):
if serializer.is_valid():
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
else:
- raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
+ raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
# If updating an existing instance, start with existing custom_field_data
if self.parent.instance:
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index 40b73f902..7dad95263 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -1,5 +1,6 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist
+from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
@@ -43,9 +44,6 @@ __all__ = (
'ImageAttachmentSerializer',
'JournalEntrySerializer',
'ObjectChangeSerializer',
- 'ReportDetailSerializer',
- 'ReportSerializer',
- 'ReportInputSerializer',
'SavedFilterSerializer',
'ScriptDetailSerializer',
'ScriptInputSerializer',
@@ -78,15 +76,16 @@ class EventRuleSerializer(NetBoxModelSerializer):
'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
@extend_schema_field(OpenApiTypes.OBJECT)
def get_action_object(self, instance):
context = {'request': self.context['request']}
# We need to manually instantiate the serializer for scripts
if instance.action_type == EventRuleActionChoices.SCRIPT:
- script_name = instance.action_parameters['script_name']
- script = instance.action_object.scripts[script_name]()
- return NestedScriptSerializer(script, context=context).data
+ script = instance.action_object
+ instance = script.python_class() if script.python_class else None
+ return NestedScriptSerializer(instance, context=context).data
else:
serializer = get_serializer_for_model(
model=instance.action_object_type.model_class(),
@@ -109,6 +108,7 @@ class WebhookSerializer(NetBoxModelSerializer):
'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields',
'tags', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
#
@@ -144,10 +144,11 @@ class CustomFieldSerializer(ValidatedModelSerializer):
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
def validate_type(self, value):
if self.instance and self.instance.type != value:
- raise serializers.ValidationError('Changing the type of custom fields is not supported.')
+ raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
return value
@@ -186,6 +187,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
'choices_count', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')
#
@@ -205,6 +207,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name')
#
@@ -231,6 +234,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
#
@@ -250,6 +254,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
'shared', 'parameters', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
#
@@ -269,6 +274,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
]
+ brief_fields = ('id', 'url', 'display', 'object_id', 'object_type')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_object(self, instance):
@@ -297,6 +303,7 @@ class TagSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')
#
@@ -316,6 +323,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
'image_width', 'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'image')
def validate(self, data):
@@ -365,6 +373,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'created')
def validate(self, data):
@@ -488,6 +497,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
'created', 'last_updated',
]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
#
@@ -509,81 +519,58 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
]
-
-
-#
-# Reports
-#
-
-class ReportSerializer(serializers.Serializer):
- url = serializers.HyperlinkedIdentityField(
- view_name='extras-api:report-detail',
- lookup_field='full_name',
- lookup_url_kwarg='pk'
- )
- id = serializers.CharField(read_only=True, source="full_name")
- module = serializers.CharField(max_length=255)
- name = serializers.CharField(max_length=255)
- description = serializers.CharField(max_length=255, required=False)
- test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
- result = NestedJobSerializer()
- display = serializers.SerializerMethodField(read_only=True)
-
- @extend_schema_field(serializers.CharField())
- def get_display(self, obj):
- return f'{obj.name} ({obj.module})'
-
-
-class ReportDetailSerializer(ReportSerializer):
- result = JobSerializer()
-
-
-class ReportInputSerializer(serializers.Serializer):
- schedule_at = serializers.DateTimeField(required=False, allow_null=True)
- interval = serializers.IntegerField(required=False, allow_null=True)
-
- def validate_schedule_at(self, value):
- if value and not self.context['report'].scheduling_enabled:
- raise serializers.ValidationError("Scheduling is not enabled for this report.")
- return value
-
- def validate_interval(self, value):
- if value and not self.context['report'].scheduling_enabled:
- raise serializers.ValidationError("Scheduling is not enabled for this report.")
- return value
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
#
# Scripts
#
-class ScriptSerializer(serializers.Serializer):
- url = serializers.HyperlinkedIdentityField(
- view_name='extras-api:script-detail',
- lookup_field='full_name',
- lookup_url_kwarg='pk'
- )
- id = serializers.CharField(read_only=True, source="full_name")
- module = serializers.CharField(max_length=255)
- name = serializers.CharField(read_only=True)
- description = serializers.CharField(read_only=True)
+class ScriptSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
+ description = serializers.SerializerMethodField(read_only=True)
vars = serializers.SerializerMethodField(read_only=True)
- result = NestedJobSerializer()
- display = serializers.SerializerMethodField(read_only=True)
+ result = NestedJobSerializer(read_only=True)
+
+ class Meta:
+ model = Script
+ fields = [
+ 'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
+ ]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
@extend_schema_field(serializers.JSONField(allow_null=True))
- def get_vars(self, instance):
- return {
- k: v.__class__.__name__ for k, v in instance._get_vars().items()
- }
+ def get_vars(self, obj):
+ if obj.python_class:
+ return {
+ k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
+ }
+ else:
+ return {}
@extend_schema_field(serializers.CharField())
def get_display(self, obj):
return f'{obj.name} ({obj.module})'
+ @extend_schema_field(serializers.CharField())
+ def get_description(self, obj):
+ if obj.python_class:
+ return obj.python_class().description
+ else:
+ return None
+
class ScriptDetailSerializer(ScriptSerializer):
- result = JobSerializer()
+ result = serializers.SerializerMethodField(read_only=True)
+
+ @extend_schema_field(JobSerializer())
+ def get_result(self, obj):
+ job = obj.jobs.all().order_by('-created').first()
+ context = {
+ 'request': self.context['request']
+ }
+ data = JobSerializer(job, context=context).data
+ return data
class ScriptInputSerializer(serializers.Serializer):
@@ -594,12 +581,12 @@ class ScriptInputSerializer(serializers.Serializer):
def validate_schedule_at(self, value):
if value and not self.context['script'].scheduling_enabled:
- raise serializers.ValidationError("Scheduling is not enabled for this script.")
+ raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value
def validate_interval(self, value):
if value and not self.context['script'].scheduling_enabled:
- raise serializers.ValidationError("Scheduling is not enabled for this script.")
+ raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index db26a6f6c..72450f9c9 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType
-from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
@@ -9,14 +8,13 @@ from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
-from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
+from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from rq import Worker
-from core.choices import JobStatusChoices
from core.models import Job
from extras import filtersets
from extras.models import *
-from extras.scripts import get_module_and_script, run_script
+from extras.scripts import run_script
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata
@@ -209,66 +207,30 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
# Scripts
#
-class ScriptViewSet(ViewSet):
+class ScriptViewSet(ModelViewSet):
permission_classes = [IsAuthenticatedOrLoginNotRequired]
+ queryset = Script.objects.prefetch_related('jobs')
+ serializer_class = serializers.ScriptSerializer
+ filterset_class = filtersets.ScriptFilterSet
+
_ignore_model_permissions = True
- schema = None
lookup_value_regex = '[^/]+' # Allow dots
- def _get_script(self, pk):
- try:
- module_name, script_name = pk.split('.', maxsplit=1)
- except ValueError:
- raise Http404
-
- module, script = get_module_and_script(module_name, script_name)
- if script is None:
- raise Http404
-
- return module, script
-
- def list(self, request):
- results = {
- job.name: job
- for job in Job.objects.filter(
- object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
- status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
- ).order_by('name', '-created').distinct('name').defer('data')
- }
-
- script_list = []
- for script_module in ScriptModule.objects.restrict(request.user):
- script_list.extend(script_module.scripts.values())
-
- # Attach Job objects to each script (if any)
- for script in script_list:
- script.result = results.get(script.class_name, None)
-
- serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
-
- return Response({'count': len(script_list), 'results': serializer.data})
-
def retrieve(self, request, pk):
- module, script = self._get_script(pk)
- object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
- script.result = Job.objects.filter(
- object_type=object_type,
- name=script.class_name,
- status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
- ).first()
+ script = get_object_or_404(self.queryset, pk=pk)
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
return Response(serializer.data)
def post(self, request, pk):
"""
- Run a Script identified as ".