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 @@ License Contributors GitHub stars - Languages supported + Languages supported CI status

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 ".