diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b0b8c02ad..612d01d89 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -17,15 +17,16 @@ body: 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 + - NetBox Enterprise + - Self-hosted validations: required: true - type: input attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v3.7.3 + placeholder: v3.7.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index bc99999c0..8eb47180d 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.3 + placeholder: v3.7.4 validations: required: true - type: dropdown diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af3d303b2..d794786f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,4 +84,4 @@ jobs: run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel - name: Show coverage report - run: coverage report --skip-covered --omit *migrations* + run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*' diff --git a/base_requirements.txt b/base_requirements.txt index 49d259349..383628fe3 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -101,7 +101,7 @@ markdown-include mkdocs-material # Introspection for embedded code -# https://github.com/mkdocstrings/mkdocstrings/blob/master/CHANGELOG.md +# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md mkdocstrings[python-legacy] # Library for manipulating IP prefixes and addresses diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 5e8507798..1164f2e48 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -384,7 +384,10 @@ "8gfc-sfpp", "16gfc-sfpp", "32gfc-sfp28", + "32gfc-sfpp", "64gfc-qsfpp", + "64gfc-sfpdd", + "64gfc-sfpp", "128gfc-qsfp28", "infiniband-sdr", "infiniband-ddr", diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 671f3ab17..bdc3f9104 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -476,7 +476,7 @@ class NewBranchScript(Script): name=f'{site.slug}-switch{i}', site=site, status=DeviceStatusChoices.STATUS_PLANNED, - device_role=switch_role + role=switch_role ) switch.full_clean() switch.save() diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 184fc26d2..9d30f4514 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -31,8 +31,7 @@ This section entails the installation and configuration of a local PostgreSQL da Once PostgreSQL has been installed, start the service and enable it to run at boot: ```no-highlight - sudo systemctl start postgresql - sudo systemctl enable postgresql + sudo systemctl enable --now postgresql ``` Before continuing, verify that you have installed PostgreSQL 12 or later: diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md index 7c364947e..2756a1ab0 100644 --- a/docs/installation/2-redis.md +++ b/docs/installation/2-redis.md @@ -14,8 +14,7 @@ ```no-highlight sudo yum install -y redis - sudo systemctl start redis - sudo systemctl enable redis + sudo systemctl enable --now redis ``` Before continuing, verify that your installed version of Redis is at least v4.0: diff --git a/docs/installation/4-gunicorn.md b/docs/installation/4-gunicorn.md index e31c48466..1e8d49453 100644 --- a/docs/installation/4-gunicorn.md +++ b/docs/installation/4-gunicorn.md @@ -27,8 +27,7 @@ sudo systemctl daemon-reload Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: ```no-highlight -sudo systemctl start netbox netbox-rq -sudo systemctl enable netbox netbox-rq +sudo systemctl enable --now netbox netbox-rq ``` You can use the command `systemctl status netbox` to verify that the WSGI service is running: 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/models/extras/customfield.md b/docs/models/extras/customfield.md index e68ddb79d..495c4e2e8 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -38,7 +38,7 @@ The type of data this field holds. This must be one of the following: | Object | A single NetBox object of the type defined by `object_type` | | Multiple object | One or more NetBox objects of the type defined by `object_type` | -### Object Type +### Related Object Type For object and multiple-object fields only. Designates the type of NetBox object being referenced. diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 21e7489c3..9724c4488 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -1,6 +1,31 @@ # NetBox v3.7 -## v3.7.4 (FUTURE) +## v3.7.5 (FUTURE) + +--- + +## v3.7.4 (2024-03-13) + +### Enhancements + +* [#14206](https://github.com/netbox-community/netbox/issues/14206) - Add additional FibreChannel SFP+ interface types +* [#14366](https://github.com/netbox-community/netbox/issues/14366) - Enable custom links for config contexts & templates +* [#15291](https://github.com/netbox-community/netbox/issues/15291) - Add tunnel termination buttons to VM interfaces table +* [#15297](https://github.com/netbox-community/netbox/issues/15297) - Linkify platform column in device & virtual machine tables + +### Bug Fixes + +* [#13722](https://github.com/netbox-community/netbox/issues/13722) - Fix range expansion for comma-separated numerical values +* [#14832](https://github.com/netbox-community/netbox/issues/14832) - Enable querying IP addresses for an FHRP group via GraphQL +* [#15220](https://github.com/netbox-community/netbox/issues/15220) - Fix validation check when bulk editing the mask length of IP addresses +* [#15232](https://github.com/netbox-community/netbox/issues/15232) - Permit user with sufficient permissions to assign an inventory item to a device type +* [#15241](https://github.com/netbox-community/netbox/issues/15241) - Restore missing `display` field on VirtualDisk serialization in REST API +* [#15243](https://github.com/netbox-community/netbox/issues/15243) - Correct representation of installed module when listing module bays using REST API brief mode +* [#15316](https://github.com/netbox-community/netbox/issues/15316) - Fix selection of 3DES encryption for IKE & IPSec proposals +* [#15322](https://github.com/netbox-community/netbox/issues/15322) - Add description field to YAML export for device & module types +* [#15336](https://github.com/netbox-community/netbox/issues/15336) - Correct label for recurring scheduled jobs +* [#15347](https://github.com/netbox-community/netbox/issues/15347) - Fix querying virtual machine contacts via GraphQL +* [#15356](https://github.com/netbox-community/netbox/issues/15356) - Fix assignment of front & rear images to device types via REST API --- diff --git a/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md index 4bae93fa8..60b3115f0 100644 --- a/docs/release-notes/version-4.0.md +++ b/docs/release-notes/version-4.0.md @@ -34,7 +34,7 @@ The REST API now supports specifying which fields to include in the response dat * [#12325](https://github.com/netbox-community/netbox/issues/12325) - The Django admin UI is now disabled by default (set `DJANGO_ADMIN_ENABLED` to True to enable it) * [#12510](https://github.com/netbox-community/netbox/issues/12510) - Dropped support for legacy reports -* [#12795](https://github.com/netbox-community/netbox/issues/12795) - NetBox now uses a custom User model rather than the stock model provided by Django +* [#12795](https://github.com/netbox-community/netbox/issues/12795) - NetBox now uses custom User and Group models rather than the stock models provided by Django * [#13647](https://github.com/netbox-community/netbox/issues/13647) - Squash all database migrations prior to v3.7 * [#14092](https://github.com/netbox-community/netbox/issues/14092) - Remove backward compatibility for importing plugin resources from `extras.plugins` (now `netbox.plugins`) * [#14638](https://github.com/netbox-community/netbox/issues/14638) - Drop support for Python 3.8 and 3.9 @@ -44,3 +44,37 @@ The REST API now supports specifying which fields to include in the response dat * [#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 +* [#15277](https://github.com/netbox-community/netbox/issues/15277) - Replace references to ContentType without ObjectType proxy model & standardize field names +* [#15292](https://github.com/netbox-community/netbox/issues/15292) - Remove obsolete `device_role` attribute from Device model (this field was renamed to `role` in v3.6) + +### REST API Changes + +* The `/api/extras/content-types/` endpoint has moved to `/api/extras/object-types/` +* dcim.Device + * The obsolete read-only attribute `device_role` has been removed (replaced by `role` in v3.6) +* extras.CustomField + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` +* extras.CustomLink + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` +* extras.EventRule + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` +* extras.ExportTemplate + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` +* extras.ImageAttachment + * `content_type` has been renamed to `object_type` + * The `content_type` filter is now `object_type` +* extras.SavedFilter + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` +* tenancy.ContactAssignment + * `content_type` has been renamed to `object_type` + * The `content_type_id` filter is now `object_type_id` diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 574924c4a..5e048218c 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,145 +1,3 @@ -from rest_framework import serializers - -from circuits.choices import CircuitStatusChoices -from circuits.models import * -from dcim.api.nested_serializers import NestedSiteSerializer -from dcim.api.serializers import CabledObjectSerializer -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 +from .serializers_.providers import * +from .serializers_.circuits import * from .nested_serializers import * - - -# -# Providers -# - -class ProviderSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') - accounts = SerializedPKRelatedField( - queryset=ProviderAccount.objects.all(), - serializer=NestedProviderAccountSerializer, - required=False, - many=True - ) - asns = SerializedPKRelatedField( - queryset=ASN.objects.all(), - serializer=NestedASNSerializer, - required=False, - many=True - ) - - # Related object counts - circuit_count = RelatedObjectCountField('circuits') - - class Meta: - model = Provider - fields = [ - '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') - - -# -# Provider Accounts -# - -class ProviderAccountSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail') - provider = NestedProviderSerializer() - - class Meta: - model = ProviderAccount - fields = [ - 'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'account', 'description') - - -# -# Provider networks -# - -class ProviderNetworkSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') - provider = NestedProviderSerializer() - - class Meta: - model = ProviderNetwork - fields = [ - 'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - -# -# Circuits -# - -class CircuitTypeSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') - - # Related object counts - circuit_count = RelatedObjectCountField('circuits') - - class Meta: - model = CircuitType - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') - site = NestedSiteSerializer(allow_null=True) - provider_network = NestedProviderNetworkSerializer(allow_null=True) - - class Meta: - model = CircuitTermination - fields = [ - 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', - 'description', - ] - - -class CircuitSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') - provider = NestedProviderSerializer() - provider_account = NestedProviderAccountSerializer(required=False, allow_null=True) - status = ChoiceField(choices=CircuitStatusChoices, required=False) - type = NestedCircuitTypeSerializer() - tenant = NestedTenantSerializer(required=False, allow_null=True) - termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True) - termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True) - - class Meta: - model = Circuit - fields = [ - 'id', 'url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', - '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): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') - circuit = NestedCircuitSerializer() - site = NestedSiteSerializer(required=False, allow_null=True) - provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True) - - class Meta: - model = CircuitTermination - fields = [ - 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', - '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/api/serializers_/__init__.py b/netbox/circuits/api/serializers_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/circuits/api/serializers_/circuits.py b/netbox/circuits/api/serializers_/circuits.py new file mode 100644 index 000000000..b59c73f09 --- /dev/null +++ b/netbox/circuits/api/serializers_/circuits.py @@ -0,0 +1,81 @@ +from rest_framework import serializers + +from circuits.choices import CircuitStatusChoices +from circuits.models import Circuit, CircuitTermination, CircuitType +from dcim.api.serializers_.cables import CabledObjectSerializer +from dcim.api.serializers_.sites import SiteSerializer +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer +from tenancy.api.serializers_.tenants import TenantSerializer + +from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer + +__all__ = ( + 'CircuitSerializer', + 'CircuitTerminationSerializer', + 'CircuitTypeSerializer', +) + + +class CircuitTypeSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') + + # Related object counts + circuit_count = RelatedObjectCountField('circuits') + + class Meta: + model = CircuitType + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') + site = SiteSerializer(nested=True, allow_null=True) + provider_network = ProviderNetworkSerializer(nested=True, allow_null=True) + + class Meta: + model = CircuitTermination + fields = [ + 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', + 'description', + ] + + +class CircuitSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') + provider = ProviderSerializer(nested=True) + provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True) + status = ChoiceField(choices=CircuitStatusChoices, required=False) + type = CircuitTypeSerializer(nested=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True) + termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True) + + class Meta: + model = Circuit + fields = [ + 'id', 'url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', + '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): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') + circuit = CircuitSerializer(nested=True) + site = SiteSerializer(nested=True, required=False, allow_null=True) + provider_network = ProviderNetworkSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = CircuitTermination + fields = [ + 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', + '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/api/serializers_/providers.py b/netbox/circuits/api/serializers_/providers.py new file mode 100644 index 000000000..302c2da5a --- /dev/null +++ b/netbox/circuits/api/serializers_/providers.py @@ -0,0 +1,68 @@ +from rest_framework import serializers + +from circuits.models import Provider, ProviderAccount, ProviderNetwork +from ipam.api.serializers_.asns import ASNSerializer +from ipam.models import ASN +from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField +from netbox.api.serializers import NetBoxModelSerializer +from ..nested_serializers import * + +__all__ = ( + 'ProviderAccountSerializer', + 'ProviderNetworkSerializer', + 'ProviderSerializer', +) + + +class ProviderSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') + accounts = SerializedPKRelatedField( + queryset=ProviderAccount.objects.all(), + serializer=NestedProviderAccountSerializer, + required=False, + many=True + ) + asns = SerializedPKRelatedField( + queryset=ASN.objects.all(), + serializer=ASNSerializer, + nested=True, + required=False, + many=True + ) + + # Related object counts + circuit_count = RelatedObjectCountField('circuits') + + class Meta: + model = Provider + fields = [ + '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') + + +class ProviderAccountSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail') + provider = ProviderSerializer(nested=True) + + class Meta: + model = ProviderAccount + fields = [ + 'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'account', 'description') + + +class ProviderNetworkSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') + provider = ProviderSerializer(nested=True) + + class Meta: + model = ProviderNetwork + fields = [ + 'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 97be1cf57..cbf1fb82d 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -67,7 +67,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class Meta: model = Provider - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -95,7 +95,7 @@ class ProviderAccountFilterSet(NetBoxModelFilterSet): class Meta: model = ProviderAccount - fields = ['id', 'name', 'account', 'description'] + fields = ('id', 'name', 'account', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -122,7 +122,7 @@ class ProviderNetworkFilterSet(NetBoxModelFilterSet): class Meta: model = ProviderNetwork - fields = ['id', 'name', 'service_id', 'description'] + fields = ('id', 'name', 'service_id', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -139,7 +139,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet): class Meta: model = CircuitType - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ('id', 'name', 'slug', 'color', 'description') class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -158,6 +158,12 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte queryset=ProviderAccount.objects.all(), label=_('Provider account (ID)'), ) + provider_account = django_filters.ModelMultipleChoiceFilter( + field_name='provider_account__account', + queryset=Provider.objects.all(), + to_field_name='account', + label=_('Provider account (account)'), + ) provider_network_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__provider_network', queryset=ProviderNetwork.objects.all(), @@ -214,10 +220,18 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte to_field_name='slug', label=_('Site (slug)'), ) + termination_a_id = django_filters.ModelMultipleChoiceFilter( + queryset=CircuitTermination.objects.all(), + label=_('Termination A (ID)'), + ) + termination_z_id = django_filters.ModelMultipleChoiceFilter( + queryset=CircuitTermination.objects.all(), + label=_('Termination A (ID)'), + ) class Meta: model = Circuit - fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate'] + fields = ('id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate') def search(self, queryset, name, value): if not value.strip(): @@ -258,7 +272,10 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): class Meta: model = CircuitTermination - fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end'] + fields = ( + 'id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected', + 'pp_info', 'cable_end', + ) def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 6553179ec..bbd2438d7 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -330,6 +330,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CircuitTermination.objects.all() filterset = CircuitTerminationFilterSet + ignore_fields = ('cable',) @classmethod def setUpTestData(cls): diff --git a/netbox/core/api/nested_serializers.py b/netbox/core/api/nested_serializers.py index d99738cbe..efb748ee0 100644 --- a/netbox/core/api/nested_serializers.py +++ b/netbox/core/api/nested_serializers.py @@ -4,7 +4,7 @@ from core.choices import JobStatusChoices from core.models import * from netbox.api.fields import ChoiceField from netbox.api.serializers import WritableNestedSerializer -from users.api.nested_serializers import NestedUserSerializer +from users.api.serializers import UserSerializer __all__ = ( 'NestedDataFileSerializer', @@ -32,7 +32,8 @@ class NestedDataFileSerializer(WritableNestedSerializer): class NestedJobSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail') status = ChoiceField(choices=JobStatusChoices) - user = NestedUserSerializer( + user = UserSerializer( + nested=True, read_only=True ) diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py index be3e9ff4a..8553bb91c 100644 --- a/netbox/core/api/serializers.py +++ b/netbox/core/api/serializers.py @@ -1,74 +1,3 @@ -from rest_framework import serializers - -from core.choices import * -from core.models import * -from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField -from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer -from netbox.utils import get_data_backend_choices -from users.api.nested_serializers import NestedUserSerializer +from .serializers_.data import * +from .serializers_.jobs import * from .nested_serializers import * - -__all__ = ( - 'DataFileSerializer', - 'DataSourceSerializer', - 'JobSerializer', -) - - -class DataSourceSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='core-api:datasource-detail' - ) - type = ChoiceField( - choices=get_data_backend_choices() - ) - status = ChoiceField( - choices=DataSourceStatusChoices, - read_only=True - ) - - # Related object counts - file_count = RelatedObjectCountField('datafiles') - - class Meta: - model = DataSource - fields = [ - '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): - url = serializers.HyperlinkedIdentityField( - view_name='core-api:datafile-detail' - ) - source = NestedDataSourceSerializer( - read_only=True - ) - - class Meta: - model = DataFile - fields = [ - 'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash', - ] - brief_fields = ('id', 'url', 'display', 'path') - - -class JobSerializer(BaseModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail') - user = NestedUserSerializer( - read_only=True - ) - status = ChoiceField(choices=JobStatusChoices, read_only=True) - object_type = ContentTypeField( - read_only=True - ) - - class Meta: - model = Job - fields = [ - '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/api/serializers_/__init__.py b/netbox/core/api/serializers_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/core/api/serializers_/data.py b/netbox/core/api/serializers_/data.py new file mode 100644 index 000000000..e60d8d95b --- /dev/null +++ b/netbox/core/api/serializers_/data.py @@ -0,0 +1,53 @@ +from rest_framework import serializers + +from core.choices import * +from core.models import DataFile, DataSource +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from netbox.utils import get_data_backend_choices + +__all__ = ( + 'DataFileSerializer', + 'DataSourceSerializer', +) + + +class DataSourceSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='core-api:datasource-detail' + ) + type = ChoiceField( + choices=get_data_backend_choices() + ) + status = ChoiceField( + choices=DataSourceStatusChoices, + read_only=True + ) + + # Related object counts + file_count = RelatedObjectCountField('datafiles') + + class Meta: + model = DataSource + fields = [ + '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): + url = serializers.HyperlinkedIdentityField( + view_name='core-api:datafile-detail' + ) + source = DataSourceSerializer( + nested=True, + read_only=True + ) + + class Meta: + model = DataFile + fields = [ + 'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash', + ] + brief_fields = ('id', 'url', 'display', 'path') diff --git a/netbox/core/api/serializers_/jobs.py b/netbox/core/api/serializers_/jobs.py new file mode 100644 index 000000000..84afa3627 --- /dev/null +++ b/netbox/core/api/serializers_/jobs.py @@ -0,0 +1,31 @@ +from rest_framework import serializers + +from core.choices import * +from core.models import Job +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import BaseModelSerializer +from users.api.serializers_.users import UserSerializer + +__all__ = ( + 'JobSerializer', +) + + +class JobSerializer(BaseModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail') + user = UserSerializer( + nested=True, + read_only=True + ) + status = ChoiceField(choices=JobStatusChoices, read_only=True) + object_type = ContentTypeField( + read_only=True + ) + + class Meta: + model = Job + fields = [ + '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/filtersets.py b/netbox/core/filtersets.py index 902e240ee..c5d332b68 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -28,7 +28,7 @@ class DataSourceFilterSet(NetBoxModelFilterSet): class Meta: model = DataSource - fields = ('id', 'name', 'enabled', 'description') + fields = ('id', 'name', 'enabled', 'description', 'source_url', 'last_synced') def search(self, queryset, name, value): if not value.strip(): @@ -115,7 +115,7 @@ class JobFilterSet(BaseFilterSet): class Meta: model = Job - fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user') + fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user', 'job_id') def search(self, queryset, name, value): if not value.strip(): @@ -134,9 +134,7 @@ class ConfigRevisionFilterSet(BaseFilterSet): class Meta: model = ConfigRevision - fields = [ - 'id', - ] + fields = ('id', 'created', 'comment') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 0c164ac29..bd74c0f14 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -68,7 +68,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): ) object_type = ContentTypeChoiceField( label=_('Object Type'), - queryset=ContentType.objects.with_feature('jobs'), + queryset=ObjectType.objects.with_feature('jobs'), required=False, ) status = forms.MultipleChoiceField( diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index eeefe502b..b96870252 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -8,7 +8,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand -from core.models import ContentType +from core.models import ObjectType APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless') @@ -60,7 +60,7 @@ class Command(BaseCommand): pass # Additional objects to include - namespace['ContentType'] = ContentType + namespace['ObjectType'] = ObjectType namespace['User'] = get_user_model() # Load convenience commands diff --git a/netbox/core/migrations/0008_contenttype_proxy.py b/netbox/core/migrations/0008_contenttype_proxy.py index ac11d906a..dee82a969 100644 --- a/netbox/core/migrations/0008_contenttype_proxy.py +++ b/netbox/core/migrations/0008_contenttype_proxy.py @@ -1,5 +1,3 @@ -# Generated by Django 4.2.6 on 2023-10-31 19:38 - import core.models.contenttypes from django.db import migrations @@ -13,7 +11,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='ContentType', + name='ObjectType', fields=[ ], options={ @@ -23,7 +21,7 @@ class Migration(migrations.Migration): }, bases=('contenttypes.contenttype',), managers=[ - ('objects', core.models.contenttypes.ContentTypeManager()), + ('objects', core.models.contenttypes.ObjectTypeManager()), ], ), ] diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index c98184c3d..b0301848f 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -1,15 +1,15 @@ -from django.contrib.contenttypes.models import ContentType as ContentType_, ContentTypeManager as ContentTypeManager_ +from django.contrib.contenttypes.models import ContentType, ContentTypeManager from django.db.models import Q from netbox.registry import registry __all__ = ( - 'ContentType', - 'ContentTypeManager', + 'ObjectType', + 'ObjectTypeManager', ) -class ContentTypeManager(ContentTypeManager_): +class ObjectTypeManager(ContentTypeManager): def public(self): """ @@ -40,11 +40,11 @@ class ContentTypeManager(ContentTypeManager_): return self.get_queryset().filter(q) -class ContentType(ContentType_): +class ObjectType(ContentType): """ Wrap Django's native ContentType model to use our custom manager. """ - objects = ContentTypeManager() + objects = ObjectTypeManager() class Meta: proxy = True diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 2e3425129..b9f0d0b91 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -11,7 +11,7 @@ from django.utils import timezone from django.utils.translation import gettext as _ from core.choices import JobStatusChoices -from core.models import ContentType +from core.models import ObjectType from core.signals import job_end, job_start from extras.constants import EVENT_JOB_END, EVENT_JOB_START from netbox.config import get_config @@ -130,7 +130,7 @@ class Job(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ContentType.objects.with_feature('jobs'): + if self.object_type not in ObjectType.objects.with_feature('jobs'): raise ValidationError( _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) ) @@ -210,7 +210,7 @@ class Job(models.Model): schedule_at: Schedule the job to be executed at the passed date and time interval: Recurrence interval (in minutes) """ - object_type = ContentType.objects.get_for_model(instance, for_concrete_model=False) + object_type = ObjectType.objects.get_for_model(instance, for_concrete_model=False) rq_queue_name = get_queue_for_model(object_type.model) queue = django_rq.get_queue(rq_queue_name) status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index 8ff104142..aefb9eed0 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -10,6 +10,7 @@ from ..models import * class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DataSource.objects.all() filterset = DataSourceFilterSet + ignore_fields = ('ignore_rules', 'parameters') @classmethod def setUpTestData(cls): @@ -70,6 +71,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DataFile.objects.all() filterset = DataFileFilterSet + ignore_fields = ('data',) @classmethod def setUpTestData(cls): diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 9a59af8e2..bcb141a71 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -6,8 +6,6 @@ from netbox.api.fields import RelatedObjectCountField from netbox.api.serializers import WritableNestedSerializer __all__ = [ - 'ComponentNestedModuleSerializer', - 'ModuleBayNestedModuleSerializer', 'NestedCableSerializer', 'NestedConsolePortSerializer', 'NestedConsolePortTemplateSerializer', @@ -319,18 +317,6 @@ class ModuleBayNestedModuleSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'serial'] -class ComponentNestedModuleSerializer(WritableNestedSerializer): - """ - Used by device component serializers. - """ - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') - module_bay = ModuleNestedModuleBaySerializer(read_only=True) - - class Meta: - model = models.Module - fields = ['id', 'url', 'display', 'device', 'module_bay'] - - class NestedModuleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') device = NestedDeviceSerializer(read_only=True) @@ -414,11 +400,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer): class NestedModuleBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') - module = NestedModuleSerializer(required=False, read_only=True, allow_null=True) + installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True) class Meta: model = models.ModuleBay - fields = ['id', 'url', 'display', 'module', 'name'] + fields = ['id', 'url', 'display', 'installed_module', 'name'] class NestedDeviceBaySerializer(WritableNestedSerializer): diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1bf4969e2..4f8bbac17 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,1310 +1,14 @@ -import decimal - -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 rest_framework import serializers -from timezone_field.rest_framework import TimeZoneSerializerField - -from dcim.choices import * -from dcim.constants import * -from dcim.models import * -from extras.api.nested_serializers import NestedConfigTemplateSerializer -from ipam.api.nested_serializers import ( - NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, -) -from ipam.models import ASN, VLAN -from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField -from netbox.api.serializers import ( - GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, - WritableNestedSerializer, -) -from netbox.config import ConfigItem -from netbox.constants import NESTED_SERIALIZER_PREFIX -from tenancy.api.nested_serializers import NestedTenantSerializer -from users.api.nested_serializers import NestedUserSerializer -from utilities.api import get_serializer_for_model -from virtualization.api.nested_serializers import NestedClusterSerializer -from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer -from wireless.api.nested_serializers import NestedWirelessLANSerializer, NestedWirelessLinkSerializer -from wireless.choices import * -from wireless.models import WirelessLAN +from .serializers_.cables import * +from .serializers_.sites import * +from .serializers_.racks import * +from .serializers_.manufacturers import * +from .serializers_.platforms import * +from .serializers_.roles import * +from .serializers_.devicetypes import * +from .serializers_.devicetype_components import * +from .serializers_.virtualchassis import * +from .serializers_.devices import * +from .serializers_.device_components import * +from .serializers_.power import * +from .serializers_.rackunits import * from .nested_serializers import * - - -class CabledObjectSerializer(serializers.ModelSerializer): - cable = NestedCableSerializer(read_only=True, allow_null=True) - cable_end = serializers.CharField(read_only=True) - link_peers_type = serializers.SerializerMethodField(read_only=True) - link_peers = serializers.SerializerMethodField(read_only=True) - _occupied = serializers.SerializerMethodField(read_only=True) - - @extend_schema_field(OpenApiTypes.STR) - def get_link_peers_type(self, obj): - """ - Return the type of the peer link terminations, or None. - """ - if not obj.cable: - return None - - if obj.link_peers: - return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}' - - return None - - @extend_schema_field(serializers.ListField) - def get_link_peers(self, obj): - """ - Return the appropriate serializer for the link termination model. - """ - if not obj.link_peers: - return [] - - # Return serialized peer termination objects - serializer = get_serializer_for_model(obj.link_peers[0], prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.link_peers, context=context, many=True).data - - @extend_schema_field(serializers.BooleanField) - def get__occupied(self, obj): - return obj._occupied - - -class ConnectedEndpointsSerializer(serializers.ModelSerializer): - """ - Legacy serializer for pre-v3.3 connections - """ - connected_endpoints_type = serializers.SerializerMethodField(read_only=True) - connected_endpoints = serializers.SerializerMethodField(read_only=True) - connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True) - - @extend_schema_field(OpenApiTypes.STR) - def get_connected_endpoints_type(self, obj): - if endpoints := obj.connected_endpoints: - return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}' - - @extend_schema_field(serializers.ListField) - def get_connected_endpoints(self, obj): - """ - Return the appropriate serializer for the type of connected object. - """ - if endpoints := obj.connected_endpoints: - serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(endpoints, many=True, context=context).data - - @extend_schema_field(serializers.BooleanField) - def get_connected_endpoints_reachable(self, obj): - return obj._path and obj._path.is_complete and obj._path.is_active - - -# -# Regions/sites -# - -class RegionSerializer(NestedGroupModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') - parent = NestedRegionSerializer(required=False, allow_null=True, default=None) - site_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Region - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') - parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) - site_count = serializers.IntegerField(read_only=True) - - class Meta: - model = SiteGroup - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') - status = ChoiceField(choices=SiteStatusChoices, required=False) - region = NestedRegionSerializer(required=False, allow_null=True) - group = NestedSiteGroupSerializer(required=False, allow_null=True) - tenant = NestedTenantSerializer(required=False, allow_null=True) - time_zone = TimeZoneSerializerField(required=False, allow_null=True) - asns = SerializedPKRelatedField( - queryset=ASN.objects.all(), - serializer=NestedASNSerializer, - required=False, - many=True - ) - - # Related object counts - circuit_count = RelatedObjectCountField('circuit_terminations') - device_count = RelatedObjectCountField('devices') - prefix_count = RelatedObjectCountField('prefixes') - rack_count = RelatedObjectCountField('racks') - vlan_count = RelatedObjectCountField('vlans') - virtualmachine_count = RelatedObjectCountField('virtual_machines') - - class Meta: - model = Site - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', - 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags', - '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') - - -# -# Racks -# - -class LocationSerializer(NestedGroupModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') - site = NestedSiteSerializer() - parent = NestedLocationSerializer(required=False, allow_null=True) - status = ChoiceField(choices=LocationStatusChoices, required=False) - tenant = NestedTenantSerializer(required=False, allow_null=True) - rack_count = serializers.IntegerField(read_only=True) - device_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Location - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') - - # Related object counts - rack_count = RelatedObjectCountField('racks') - - class Meta: - model = RackRole - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') - site = NestedSiteSerializer() - location = NestedLocationSerializer(required=False, allow_null=True, default=None) - tenant = NestedTenantSerializer(required=False, allow_null=True) - status = ChoiceField(choices=RackStatusChoices, required=False) - role = NestedRackRoleSerializer(required=False, allow_null=True) - type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False, allow_null=True) - facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'), - default=None) - width = ChoiceField(choices=RackWidthChoices, required=False) - outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True) - weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) - - # Related object counts - device_count = RelatedObjectCountField('devices') - powerfeed_count = RelatedObjectCountField('powerfeeds') - - class Meta: - model = Rack - fields = [ - 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight', 'weight_unit', - '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): - """ - A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. - """ - id = serializers.DecimalField( - max_digits=4, - decimal_places=1, - read_only=True - ) - name = serializers.CharField(read_only=True) - face = ChoiceField(choices=DeviceFaceChoices, read_only=True) - device = NestedDeviceSerializer(read_only=True) - occupied = serializers.BooleanField(read_only=True) - display = serializers.SerializerMethodField(read_only=True) - - @extend_schema_field(OpenApiTypes.STR) - def get_display(self, obj): - return obj['name'] - - -class RackReservationSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') - rack = NestedRackSerializer() - user = NestedUserSerializer() - tenant = NestedTenantSerializer(required=False, allow_null=True) - - class Meta: - model = RackReservation - fields = [ - '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): - q = serializers.CharField( - required=False, - default=None - ) - face = serializers.ChoiceField( - choices=DeviceFaceChoices, - default=DeviceFaceChoices.FACE_FRONT - ) - render = serializers.ChoiceField( - choices=RackElevationDetailRenderChoices, - default=RackElevationDetailRenderChoices.RENDER_JSON - ) - unit_width = serializers.IntegerField( - default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH') - ) - unit_height = serializers.IntegerField( - default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') - ) - legend_width = serializers.IntegerField( - default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH - ) - margin_width = serializers.IntegerField( - default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH - ) - exclude = serializers.IntegerField( - required=False, - default=None - ) - expand_devices = serializers.BooleanField( - required=False, - default=True - ) - include_images = serializers.BooleanField( - required=False, - default=True - ) - - -# -# Device/module types -# - -class ManufacturerSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') - - # Related object counts - devicetype_count = RelatedObjectCountField('device_types') - inventoryitem_count = RelatedObjectCountField('inventory_items') - platform_count = RelatedObjectCountField('platforms') - - class Meta: - model = Manufacturer - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') - manufacturer = NestedManufacturerSerializer() - default_platform = NestedPlatformSerializer(required=False, allow_null=True) - u_height = serializers.DecimalField( - max_digits=4, - decimal_places=1, - label=_('Position (U)'), - min_value=0, - default=1.0 - ) - 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) - console_server_port_template_count = serializers.IntegerField(read_only=True) - power_port_template_count = serializers.IntegerField(read_only=True) - power_outlet_template_count = serializers.IntegerField(read_only=True) - interface_template_count = serializers.IntegerField(read_only=True) - front_port_template_count = serializers.IntegerField(read_only=True) - rear_port_template_count = serializers.IntegerField(read_only=True) - device_bay_template_count = serializers.IntegerField(read_only=True) - module_bay_template_count = serializers.IntegerField(read_only=True) - inventory_item_template_count = serializers.IntegerField(read_only=True) - - # Related object counts - device_count = RelatedObjectCountField('instances') - - class Meta: - model = DeviceType - fields = [ - 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', - 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', - 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'device_count', 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count', - 'power_outlet_template_count', 'interface_template_count', 'front_port_template_count', - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') - manufacturer = NestedManufacturerSerializer() - weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) - - class Meta: - model = ModuleType - fields = [ - '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') - - -# -# Component templates -# - -class ConsolePortTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField( - choices=ConsolePortTypeChoices, - allow_blank=True, - required=False - ) - - class Meta: - model = ConsolePortTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', - 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - -class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField( - choices=ConsolePortTypeChoices, - allow_blank=True, - required=False - ) - - class Meta: - model = ConsoleServerPortTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', - 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - -class PowerPortTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField( - choices=PowerPortTypeChoices, - allow_blank=True, - required=False, - allow_null=True - ) - - class Meta: - model = PowerPortTemplate - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField( - choices=PowerOutletTypeChoices, - allow_blank=True, - required=False, - allow_null=True - ) - power_port = NestedPowerPortTemplateSerializer( - required=False, - allow_null=True - ) - feed_leg = ChoiceField( - choices=PowerOutletFeedLegChoices, - allow_blank=True, - required=False, - allow_null=True - ) - - class Meta: - model = PowerOutletTemplate - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField(choices=InterfaceTypeChoices) - bridge = NestedInterfaceTemplateSerializer( - required=False, - allow_null=True - ) - poe_mode = ChoiceField( - choices=InterfacePoEModeChoices, - required=False, - allow_blank=True, - allow_null=True - ) - poe_type = ChoiceField( - choices=InterfacePoETypeChoices, - required=False, - allow_blank=True, - allow_null=True - ) - rf_role = ChoiceField( - choices=WirelessRoleChoices, - required=False, - allow_blank=True, - allow_null=True - ) - - class Meta: - model = InterfaceTemplate - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField(choices=PortTypeChoices) - - class Meta: - model = RearPortTemplate - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField(choices=PortTypeChoices) - rear_port = NestedRearPortTemplateSerializer() - - class Meta: - model = FrontPortTemplate - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') - device_type = NestedDeviceTypeSerializer() - - class Meta: - model = ModuleBayTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created', - 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - -class DeviceBayTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') - device_type = NestedDeviceTypeSerializer() - - 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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') - device_type = NestedDeviceTypeSerializer() - parent = serializers.PrimaryKeyRelatedField( - queryset=InventoryItemTemplate.objects.all(), - allow_null=True, - default=None - ) - role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) - manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) - component_type = ContentTypeField( - queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS), - required=False, - allow_null=True - ) - component = serializers.SerializerMethodField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) - - class Meta: - model = InventoryItemTemplate - fields = [ - '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): - if obj.component is None: - return None - serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.component, context=context).data - - -# -# Devices -# - -class DeviceRoleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') - config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) - - # Related object counts - device_count = RelatedObjectCountField('devices') - virtualmachine_count = RelatedObjectCountField('virtual_machines') - - class Meta: - model = DeviceRole - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') - manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) - config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) - - # Related object counts - device_count = RelatedObjectCountField('devices') - virtualmachine_count = RelatedObjectCountField('virtual_machines') - - class Meta: - model = Platform - fields = [ - '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() - location = NestedLocationSerializer(required=False, allow_null=True, default=None) - rack = NestedRackSerializer(required=False, allow_null=True, default=None) - face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default=lambda: '') - position = serializers.DecimalField( - max_digits=4, - decimal_places=1, - allow_null=True, - label=_('Position (U)'), - min_value=decimal.Decimal(0.5), - default=None - ) - status = ChoiceField(choices=DeviceStatusChoices, required=False) - airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) - primary_ip = NestedIPAddressSerializer(read_only=True) - primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) - primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) - oob_ip = NestedIPAddressSerializer(required=False, allow_null=True) - parent_device = serializers.SerializerMethodField() - cluster = NestedClusterSerializer(required=False, allow_null=True) - virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None) - vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None) - config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) - - # Counter fields - console_port_count = serializers.IntegerField(read_only=True) - console_server_port_count = serializers.IntegerField(read_only=True) - power_port_count = serializers.IntegerField(read_only=True) - power_outlet_count = serializers.IntegerField(read_only=True) - interface_count = serializers.IntegerField(read_only=True) - front_port_count = serializers.IntegerField(read_only=True) - rear_port_count = serializers.IntegerField(read_only=True) - device_bay_count = serializers.IntegerField(read_only=True) - module_bay_count = serializers.IntegerField(read_only=True) - inventory_item_count = serializers.IntegerField(read_only=True) - - 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', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - @extend_schema_field(NestedDeviceSerializer) - def get_parent_device(self, obj): - try: - device_bay = obj.parent_bay - except DeviceBay.DoesNotExist: - return None - context = {'request': self.context['request']} - data = NestedDeviceSerializer(instance=device_bay.device, context=context).data - 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', - ] - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_config_context(self, obj): - return obj.get_config_context() - - -class VirtualDeviceContextSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') - device = NestedDeviceSerializer() - tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) - primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True) - primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) - primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) - status = ChoiceField(choices=VirtualDeviceContextStatusChoices) - - # Related object counts - interface_count = RelatedObjectCountField('interfaces') - - class Meta: - model = VirtualDeviceContext - fields = [ - 'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4', - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') - device = NestedDeviceSerializer() - module_bay = NestedModuleBaySerializer() - module_type = NestedModuleTypeSerializer() - status = ChoiceField(choices=ModuleStatusChoices, required=False) - - class Meta: - model = Module - fields = [ - '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') - - -# -# Device components -# - -class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField( - choices=ConsolePortTypeChoices, - allow_blank=True, - required=False - ) - speed = ChoiceField( - choices=ConsolePortSpeedChoices, - allow_null=True, - required=False - ) - - class Meta: - model = ConsoleServerPort - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', '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 ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField( - choices=ConsolePortTypeChoices, - allow_blank=True, - required=False - ) - speed = ChoiceField( - choices=ConsolePortSpeedChoices, - allow_null=True, - required=False - ) - - class Meta: - model = ConsolePort - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', '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 PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField( - choices=PowerOutletTypeChoices, - allow_blank=True, - required=False, - allow_null=True - ) - power_port = NestedPowerPortSerializer( - required=False, - allow_null=True - ) - feed_leg = ChoiceField( - choices=PowerOutletFeedLegChoices, - allow_blank=True, - required=False, - allow_null=True - ) - - class Meta: - model = PowerOutlet - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', - 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField( - choices=PowerPortTypeChoices, - allow_blank=True, - required=False, - allow_null=True - ) - - class Meta: - model = PowerPort - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', - 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') - device = NestedDeviceSerializer() - vdcs = SerializedPKRelatedField( - queryset=VirtualDeviceContext.objects.all(), - serializer=NestedVirtualDeviceContextSerializer, - required=False, - many=True - ) - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField(choices=InterfaceTypeChoices) - parent = NestedInterfaceSerializer(required=False, allow_null=True) - bridge = NestedInterfaceSerializer(required=False, allow_null=True) - lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) - duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True) - rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) - rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) - poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) - poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) - untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) - tagged_vlans = SerializedPKRelatedField( - queryset=VLAN.objects.all(), - serializer=NestedVLANSerializer, - required=False, - many=True - ) - vrf = NestedVRFSerializer(required=False, allow_null=True) - l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True) - wireless_link = NestedWirelessLinkSerializer(read_only=True, allow_null=True) - wireless_lans = SerializedPKRelatedField( - queryset=WirelessLAN.objects.all(), - serializer=NestedWirelessLANSerializer, - required=False, - many=True - ) - count_ipaddresses = serializers.IntegerField(read_only=True) - count_fhrp_groups = serializers.IntegerField(read_only=True) - mac_address = serializers.CharField( - required=False, - default=None, - allow_blank=True, - allow_null=True - ) - wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True) - - class Meta: - model = Interface - fields = [ - 'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', - 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', - 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', - 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', - '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): - - # Validate many-to-many VLAN assignments - device = self.instance.device if self.instance else data.get('device') - for vlan in data.get('tagged_vlans', []): - if vlan.site not in [device.site, None]: - raise serializers.ValidationError({ - 'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, or " - f"it must be global." - }) - - return super().validate(data) - - -class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField(choices=PortTypeChoices) - - class Meta: - model = RearPort - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', '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 FrontPortRearPortSerializer(WritableNestedSerializer): - """ - NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device) - """ - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') - - class Meta: - model = RearPort - fields = ['id', 'url', 'display', 'name', 'label', 'description'] - - -class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField(choices=PortTypeChoices) - rear_port = FrontPortRearPortSerializer() - - class Meta: - model = FrontPort - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') - device = NestedDeviceSerializer() - installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True) - - class Meta: - model = ModuleBay - fields = [ - 'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description') - - -class DeviceBaySerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') - device = NestedDeviceSerializer() - installed_device = NestedDeviceSerializer(required=False, allow_null=True) - - class Meta: - model = DeviceBay - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') - device = NestedDeviceSerializer() - parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) - role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) - manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) - component_type = ContentTypeField( - queryset=ContentType.objects.filter(MODULAR_COMPONENT_MODELS), - required=False, - allow_null=True - ) - component = serializers.SerializerMethodField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) - - class Meta: - model = InventoryItem - fields = [ - 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', - '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): - if obj.component is None: - return None - serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.component, context=context).data - - -# -# Device component roles -# - -class InventoryItemRoleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') - - # Related object counts - inventoryitem_count = RelatedObjectCountField('inventory_items') - - class Meta: - model = InventoryItemRole - fields = [ - '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') - - -# -# Cables -# - -class CableSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') - a_terminations = GenericObjectSerializer(many=True, required=False) - b_terminations = GenericObjectSerializer(many=True, required=False) - status = ChoiceField(choices=LinkStatusChoices, required=False) - tenant = NestedTenantSerializer(required=False, allow_null=True) - length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True) - - class Meta: - model = Cable - fields = [ - '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): - """ - Used only while tracing a cable path. - """ - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') - - class Meta: - model = Cable - fields = [ - 'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description', - ] - - -class CableTerminationSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail') - termination_type = ContentTypeField( - queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) - ) - termination = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = CableTermination - fields = [ - 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination', - 'created', 'last_updated', - ] - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_termination(self, obj): - serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.termination, context=context).data - - -class CablePathSerializer(serializers.ModelSerializer): - path = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = CablePath - fields = ['id', 'path', 'is_active', 'is_complete', 'is_split'] - - @extend_schema_field(serializers.ListField) - def get_path(self, obj): - ret = [] - for nodes in obj.path_objects: - serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - ret.append(serializer(nodes, context=context, many=True).data) - return ret - - -# -# Virtual chassis -# - -class VirtualChassisSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') - master = NestedDeviceSerializer(required=False, allow_null=True, default=None) - - # Counter fields - member_count = serializers.IntegerField(read_only=True) - - class Meta: - model = VirtualChassis - fields = [ - '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') - - -# -# Power panels -# - -class PowerPanelSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') - site = NestedSiteSerializer() - location = NestedLocationSerializer( - required=False, - allow_null=True, - default=None - ) - - # Related object counts - powerfeed_count = RelatedObjectCountField('powerfeeds') - - class Meta: - model = PowerPanel - fields = [ - '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): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') - power_panel = NestedPowerPanelSerializer() - rack = NestedRackSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField( - choices=PowerFeedTypeChoices, - default=lambda: PowerFeedTypeChoices.TYPE_PRIMARY, - ) - status = ChoiceField( - choices=PowerFeedStatusChoices, - default=lambda: PowerFeedStatusChoices.STATUS_ACTIVE, - ) - supply = ChoiceField( - choices=PowerFeedSupplyChoices, - default=lambda: PowerFeedSupplyChoices.SUPPLY_AC, - ) - phase = ChoiceField( - choices=PowerFeedPhaseChoices, - default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE, - ) - tenant = NestedTenantSerializer( - required=False, - allow_null=True - ) - - class Meta: - model = PowerFeed - fields = [ - 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', - '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/serializers_/__init__.py b/netbox/dcim/api/serializers_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dcim/api/serializers_/base.py b/netbox/dcim/api/serializers_/base.py new file mode 100644 index 000000000..48f4967e3 --- /dev/null +++ b/netbox/dcim/api/serializers_/base.py @@ -0,0 +1,37 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from utilities.api import get_serializer_for_model + +__all__ = ( + 'ConnectedEndpointsSerializer', +) + + +class ConnectedEndpointsSerializer(serializers.ModelSerializer): + """ + Legacy serializer for pre-v3.3 connections + """ + connected_endpoints_type = serializers.SerializerMethodField(read_only=True) + connected_endpoints = serializers.SerializerMethodField(read_only=True) + connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(OpenApiTypes.STR) + def get_connected_endpoints_type(self, obj): + if endpoints := obj.connected_endpoints: + return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}' + + @extend_schema_field(serializers.ListField) + def get_connected_endpoints(self, obj): + """ + Return the appropriate serializer for the type of connected object. + """ + if endpoints := obj.connected_endpoints: + serializer = get_serializer_for_model(endpoints[0]) + context = {'request': self.context['request']} + return serializer(endpoints, nested=True, many=True, context=context).data + + @extend_schema_field(serializers.BooleanField) + def get_connected_endpoints_reachable(self, obj): + return obj._path and obj._path.is_complete and obj._path.is_active diff --git a/netbox/dcim/api/serializers_/cables.py b/netbox/dcim/api/serializers_/cables.py new file mode 100644 index 000000000..94a125d0c --- /dev/null +++ b/netbox/dcim/api/serializers_/cables.py @@ -0,0 +1,126 @@ +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dcim.choices import * +from dcim.constants import * +from dcim.models import Cable, CablePath, CableTermination +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import GenericObjectSerializer, NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer +from utilities.api import get_serializer_for_model + +__all__ = ( + 'CablePathSerializer', + 'CableSerializer', + 'CableTerminationSerializer', + 'CabledObjectSerializer', + 'TracedCableSerializer', +) + + +class CableSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') + a_terminations = GenericObjectSerializer(many=True, required=False) + b_terminations = GenericObjectSerializer(many=True, required=False) + status = ChoiceField(choices=LinkStatusChoices, required=False) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True) + + class Meta: + model = Cable + fields = [ + '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): + """ + Used only while tracing a cable path. + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') + + class Meta: + model = Cable + fields = [ + 'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description', + ] + + +class CableTerminationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail') + termination_type = ContentTypeField( + queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) + ) + termination = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = CableTermination + fields = [ + 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination', + 'created', 'last_updated', + ] + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_termination(self, obj): + serializer = get_serializer_for_model(obj.termination) + context = {'request': self.context['request']} + return serializer(obj.termination, nested=True, context=context).data + + +class CablePathSerializer(serializers.ModelSerializer): + path = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = CablePath + fields = ['id', 'path', 'is_active', 'is_complete', 'is_split'] + + @extend_schema_field(serializers.ListField) + def get_path(self, obj): + ret = [] + for nodes in obj.path_objects: + serializer = get_serializer_for_model(nodes[0]) + context = {'request': self.context['request']} + ret.append(serializer(nodes, nested=True, many=True, context=context).data) + return ret + + +class CabledObjectSerializer(serializers.ModelSerializer): + cable = CableSerializer(nested=True, read_only=True, allow_null=True) + cable_end = serializers.CharField(read_only=True) + link_peers_type = serializers.SerializerMethodField(read_only=True) + link_peers = serializers.SerializerMethodField(read_only=True) + _occupied = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(OpenApiTypes.STR) + def get_link_peers_type(self, obj): + """ + Return the type of the peer link terminations, or None. + """ + if not obj.cable: + return None + + if obj.link_peers: + return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}' + + return None + + @extend_schema_field(serializers.ListField) + def get_link_peers(self, obj): + """ + Return the appropriate serializer for the link termination model. + """ + if not obj.link_peers: + return [] + + # Return serialized peer termination objects + serializer = get_serializer_for_model(obj.link_peers[0]) + context = {'request': self.context['request']} + return serializer(obj.link_peers, nested=True, many=True, context=context).data + + @extend_schema_field(serializers.BooleanField) + def get__occupied(self, obj): + return obj._occupied diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py new file mode 100644 index 000000000..87d142978 --- /dev/null +++ b/netbox/dcim/api/serializers_/device_components.py @@ -0,0 +1,368 @@ +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dcim.choices import * +from dcim.constants import * +from dcim.models import ( + ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, + RearPort, VirtualDeviceContext, +) +from ipam.api.serializers_.vlans import VLANSerializer +from ipam.api.serializers_.vrfs import VRFSerializer +from ipam.models import VLAN +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer +from utilities.api import get_serializer_for_model +from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer +from wireless.api.nested_serializers import NestedWirelessLinkSerializer +from wireless.api.serializers_.wirelesslans import WirelessLANSerializer +from wireless.choices import * +from wireless.models import WirelessLAN +from .base import ConnectedEndpointsSerializer +from .cables import CabledObjectSerializer +from .devices import DeviceSerializer, ModuleSerializer, VirtualDeviceContextSerializer +from .manufacturers import ManufacturerSerializer +from .roles import InventoryItemRoleSerializer +from ..nested_serializers import * + +__all__ = ( + 'ConsolePortSerializer', + 'ConsoleServerPortSerializer', + 'DeviceBaySerializer', + 'FrontPortSerializer', + 'InterfaceSerializer', + 'InventoryItemSerializer', + 'ModuleBaySerializer', + 'PowerOutletSerializer', + 'PowerPortSerializer', + 'RearPortSerializer', +) + + +class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField( + choices=ConsolePortTypeChoices, + allow_blank=True, + required=False + ) + speed = ChoiceField( + choices=ConsolePortSpeedChoices, + allow_null=True, + required=False + ) + + class Meta: + model = ConsoleServerPort + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', '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 ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField( + choices=ConsolePortTypeChoices, + allow_blank=True, + required=False + ) + speed = ChoiceField( + choices=ConsolePortSpeedChoices, + allow_null=True, + required=False + ) + + class Meta: + model = ConsolePort + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField( + choices=PowerPortTypeChoices, + allow_blank=True, + required=False, + allow_null=True + ) + + class Meta: + model = PowerPort + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + '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 PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField( + choices=PowerOutletTypeChoices, + allow_blank=True, + required=False, + allow_null=True + ) + power_port = PowerPortSerializer( + nested=True, + required=False, + allow_null=True + ) + feed_leg = ChoiceField( + choices=PowerOutletFeedLegChoices, + allow_blank=True, + required=False, + allow_null=True + ) + + class Meta: + model = PowerOutlet + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') + device = DeviceSerializer(nested=True) + vdcs = SerializedPKRelatedField( + queryset=VirtualDeviceContext.objects.all(), + serializer=VirtualDeviceContextSerializer, + nested=True, + required=False, + many=True + ) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField(choices=InterfaceTypeChoices) + parent = NestedInterfaceSerializer(required=False, allow_null=True) + bridge = NestedInterfaceSerializer(required=False, allow_null=True) + lag = NestedInterfaceSerializer(required=False, allow_null=True) + mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) + duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True) + rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) + rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) + poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) + poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) + untagged_vlan = VLANSerializer(nested=True, required=False, allow_null=True) + tagged_vlans = SerializedPKRelatedField( + queryset=VLAN.objects.all(), + serializer=VLANSerializer, + nested=True, + required=False, + many=True + ) + vrf = VRFSerializer(nested=True, required=False, allow_null=True) + l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) + wireless_link = NestedWirelessLinkSerializer(read_only=True, allow_null=True) + wireless_lans = SerializedPKRelatedField( + queryset=WirelessLAN.objects.all(), + serializer=WirelessLANSerializer, + nested=True, + required=False, + many=True + ) + count_ipaddresses = serializers.IntegerField(read_only=True) + count_fhrp_groups = serializers.IntegerField(read_only=True) + mac_address = serializers.CharField( + required=False, + default=None, + allow_blank=True, + allow_null=True + ) + wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True) + + class Meta: + model = Interface + fields = [ + 'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', + 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', + 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', + 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', + '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): + + # Validate many-to-many VLAN assignments + if not self.nested: + device = self.instance.device if self.instance else data.get('device') + for vlan in data.get('tagged_vlans', []): + if vlan.site not in [device.site, None]: + raise serializers.ValidationError({ + 'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, " + f"or it must be global." + }) + + return super().validate(data) + + +class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField(choices=PortTypeChoices) + + class Meta: + model = RearPort + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', '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 FrontPortRearPortSerializer(WritableNestedSerializer): + """ + NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device) + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + + class Meta: + model = RearPort + fields = ['id', 'url', 'display', 'name', 'label', 'description'] + + +class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField(choices=PortTypeChoices) + rear_port = FrontPortRearPortSerializer() + + class Meta: + model = FrontPort + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + device = DeviceSerializer(nested=True) + installed_module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'serial', 'description'), + required=False, + allow_null=True + ) + + class Meta: + model = ModuleBay + fields = [ + 'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description') + + +class DeviceBaySerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') + device = DeviceSerializer(nested=True) + installed_device = DeviceSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = DeviceBay + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') + device = DeviceSerializer(nested=True) + parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) + role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True) + manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True, default=None) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = InventoryItem + fields = [ + 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', + '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): + if obj.component is None: + return None + serializer = get_serializer_for_model(obj.component) + context = {'request': self.context['request']} + return serializer(obj.component, nested=True, context=context).data diff --git a/netbox/dcim/api/serializers_/devices.py b/netbox/dcim/api/serializers_/devices.py new file mode 100644 index 000000000..303c35532 --- /dev/null +++ b/netbox/dcim/api/serializers_/devices.py @@ -0,0 +1,157 @@ +import decimal + +from django.utils.translation import gettext as _ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dcim.choices import * +from dcim.models import Device, DeviceBay, Module, VirtualDeviceContext +from extras.api.serializers_.configtemplates import ConfigTemplateSerializer +from ipam.api.serializers_.ip import IPAddressSerializer +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer +from virtualization.api.serializers_.clusters import ClusterSerializer +from .devicetypes import * +from .platforms import PlatformSerializer +from .racks import RackSerializer +from .roles import DeviceRoleSerializer +from .sites import LocationSerializer, SiteSerializer +from .virtualchassis import VirtualChassisSerializer +from ..nested_serializers import * + +__all__ = ( + 'DeviceSerializer', + 'DeviceWithConfigContextSerializer', + 'ModuleSerializer', + 'VirtualDeviceContextSerializer', +) + + +class DeviceSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + device_type = DeviceTypeSerializer(nested=True) + role = DeviceRoleSerializer(nested=True) + tenant = TenantSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + platform = PlatformSerializer(nested=True, required=False, allow_null=True) + site = SiteSerializer(nested=True) + location = LocationSerializer(nested=True, required=False, allow_null=True, default=None) + rack = RackSerializer(nested=True, required=False, allow_null=True, default=None) + face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default=lambda: '') + position = serializers.DecimalField( + max_digits=4, + decimal_places=1, + allow_null=True, + label=_('Position (U)'), + min_value=decimal.Decimal(0.5), + default=None + ) + status = ChoiceField(choices=DeviceStatusChoices, required=False) + airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) + primary_ip = IPAddressSerializer(nested=True, read_only=True) + primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True) + primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True) + oob_ip = IPAddressSerializer(nested=True, required=False, allow_null=True) + parent_device = serializers.SerializerMethodField() + cluster = ClusterSerializer(nested=True, required=False, allow_null=True) + virtual_chassis = VirtualChassisSerializer(nested=True, required=False, allow_null=True, default=None) + vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None) + config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) + + # Counter fields + console_port_count = serializers.IntegerField(read_only=True) + console_server_port_count = serializers.IntegerField(read_only=True) + power_port_count = serializers.IntegerField(read_only=True) + power_outlet_count = serializers.IntegerField(read_only=True) + interface_count = serializers.IntegerField(read_only=True) + front_port_count = serializers.IntegerField(read_only=True) + rear_port_count = serializers.IntegerField(read_only=True) + device_bay_count = serializers.IntegerField(read_only=True) + module_bay_count = serializers.IntegerField(read_only=True) + inventory_item_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Device + fields = [ + '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): + try: + device_bay = obj.parent_bay + except DeviceBay.DoesNotExist: + return None + context = {'request': self.context['request']} + data = NestedDeviceSerializer(instance=device_bay.device, context=context).data + data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data + return data + + +class DeviceWithConfigContextSerializer(DeviceSerializer): + config_context = serializers.SerializerMethodField(read_only=True) + + class Meta(DeviceSerializer.Meta): + fields = [ + '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)) + def get_config_context(self, obj): + return obj.get_config_context() + + +class VirtualDeviceContextSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') + device = DeviceSerializer(nested=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None) + primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True) + primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True) + primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True) + status = ChoiceField(choices=VirtualDeviceContextStatusChoices) + + # Related object counts + interface_count = RelatedObjectCountField('interfaces') + + class Meta: + model = VirtualDeviceContext + fields = [ + 'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4', + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = DeviceSerializer(nested=True) + module_bay = NestedModuleBaySerializer() + module_type = ModuleTypeSerializer(nested=True) + status = ChoiceField(choices=ModuleStatusChoices, required=False) + + class Meta: + model = Module + fields = [ + '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') diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py new file mode 100644 index 000000000..259a5df27 --- /dev/null +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -0,0 +1,327 @@ +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dcim.choices import * +from dcim.constants import * +from dcim.models import ( + ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate, + InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, +) +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer +from utilities.api import get_serializer_for_model +from wireless.choices import * +from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer +from .manufacturers import ManufacturerSerializer +from .roles import InventoryItemRoleSerializer +from ..nested_serializers import * + +__all__ = ( + 'ConsolePortTemplateSerializer', + 'ConsoleServerPortTemplateSerializer', + 'DeviceBayTemplateSerializer', + 'FrontPortTemplateSerializer', + 'InterfaceTemplateSerializer', + 'InventoryItemTemplateSerializer', + 'ModuleBayTemplateSerializer', + 'PowerOutletTemplateSerializer', + 'PowerPortTemplateSerializer', + 'RearPortTemplateSerializer', +) + + +class ConsolePortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=ConsolePortTypeChoices, + allow_blank=True, + required=False + ) + + class Meta: + model = ConsolePortTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=ConsolePortTypeChoices, + allow_blank=True, + required=False + ) + + class Meta: + model = ConsoleServerPortTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class PowerPortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=PowerPortTypeChoices, + allow_blank=True, + required=False, + allow_null=True + ) + + class Meta: + model = PowerPortTemplate + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=PowerOutletTypeChoices, + allow_blank=True, + required=False, + allow_null=True + ) + power_port = PowerPortTemplateSerializer( + nested=True, + required=False, + allow_null=True + ) + feed_leg = ChoiceField( + choices=PowerOutletFeedLegChoices, + allow_blank=True, + required=False, + allow_null=True + ) + + class Meta: + model = PowerOutletTemplate + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField(choices=InterfaceTypeChoices) + bridge = NestedInterfaceTemplateSerializer( + required=False, + allow_null=True + ) + poe_mode = ChoiceField( + choices=InterfacePoEModeChoices, + required=False, + allow_blank=True, + allow_null=True + ) + poe_type = ChoiceField( + choices=InterfacePoETypeChoices, + required=False, + allow_blank=True, + allow_null=True + ) + rf_role = ChoiceField( + choices=WirelessRoleChoices, + required=False, + allow_blank=True, + allow_null=True + ) + + class Meta: + model = InterfaceTemplate + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') + device_type = DeviceTypeSerializer( + required=False, + nested=True, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField(choices=PortTypeChoices) + + class Meta: + model = RearPortTemplate + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField(choices=PortTypeChoices) + rear_port = RearPortTemplateSerializer(nested=True) + + class Meta: + model = FrontPortTemplate + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') + device_type = DeviceTypeSerializer( + nested=True + ) + + class Meta: + model = ModuleBayTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class DeviceBayTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') + device_type = DeviceTypeSerializer( + nested=True + ) + + 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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') + device_type = DeviceTypeSerializer( + nested=True + ) + parent = serializers.PrimaryKeyRelatedField( + queryset=InventoryItemTemplate.objects.all(), + allow_null=True, + default=None + ) + role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True) + manufacturer = ManufacturerSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = InventoryItemTemplate + fields = [ + '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): + if obj.component is None: + return None + serializer = get_serializer_for_model(obj.component) + context = {'request': self.context['request']} + return serializer(obj.component, nested=True, context=context).data diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py new file mode 100644 index 000000000..0bd8ba824 --- /dev/null +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -0,0 +1,74 @@ +from django.utils.translation import gettext as _ +from rest_framework import serializers + +from dcim.choices import * +from dcim.models import DeviceType, ModuleType +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from .manufacturers import ManufacturerSerializer +from .platforms import PlatformSerializer + +__all__ = ( + 'DeviceTypeSerializer', + 'ModuleTypeSerializer', +) + + +class DeviceTypeSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') + manufacturer = ManufacturerSerializer(nested=True) + default_platform = PlatformSerializer(nested=True, required=False, allow_null=True) + u_height = serializers.DecimalField( + max_digits=4, + decimal_places=1, + label=_('Position (U)'), + min_value=0, + default=1.0 + ) + 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.ImageField(required=False, allow_null=True) + rear_image = serializers.ImageField(required=False, allow_null=True) + + # Counter fields + console_port_template_count = serializers.IntegerField(read_only=True) + console_server_port_template_count = serializers.IntegerField(read_only=True) + power_port_template_count = serializers.IntegerField(read_only=True) + power_outlet_template_count = serializers.IntegerField(read_only=True) + interface_template_count = serializers.IntegerField(read_only=True) + front_port_template_count = serializers.IntegerField(read_only=True) + rear_port_template_count = serializers.IntegerField(read_only=True) + device_bay_template_count = serializers.IntegerField(read_only=True) + module_bay_template_count = serializers.IntegerField(read_only=True) + inventory_item_template_count = serializers.IntegerField(read_only=True) + + # Related object counts + device_count = RelatedObjectCountField('instances') + + class Meta: + model = DeviceType + fields = [ + 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', + 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', + 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'device_count', 'console_port_template_count', 'console_server_port_template_count', + 'power_port_template_count', 'power_outlet_template_count', 'interface_template_count', + 'front_port_template_count', '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') + manufacturer = ManufacturerSerializer(nested=True) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) + + class Meta: + model = ModuleType + fields = [ + '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') diff --git a/netbox/dcim/api/serializers_/manufacturers.py b/netbox/dcim/api/serializers_/manufacturers.py new file mode 100644 index 000000000..fd50fe97d --- /dev/null +++ b/netbox/dcim/api/serializers_/manufacturers.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from dcim.models import Manufacturer +from netbox.api.fields import RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer + +__all__ = ( + 'ManufacturerSerializer', +) + + +class ManufacturerSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') + + # Related object counts + devicetype_count = RelatedObjectCountField('device_types') + inventoryitem_count = RelatedObjectCountField('inventory_items') + platform_count = RelatedObjectCountField('platforms') + + class Meta: + model = Manufacturer + fields = [ + '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') diff --git a/netbox/dcim/api/serializers_/platforms.py b/netbox/dcim/api/serializers_/platforms.py new file mode 100644 index 000000000..7365404eb --- /dev/null +++ b/netbox/dcim/api/serializers_/platforms.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + +from dcim.models import Platform +from extras.api.serializers_.configtemplates import ConfigTemplateSerializer +from netbox.api.fields import RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from .manufacturers import ManufacturerSerializer + +__all__ = ( + 'PlatformSerializer', +) + + +class PlatformSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') + manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True) + config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) + + # Related object counts + device_count = RelatedObjectCountField('devices') + virtualmachine_count = RelatedObjectCountField('virtual_machines') + + class Meta: + model = Platform + fields = [ + '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') diff --git a/netbox/dcim/api/serializers_/power.py b/netbox/dcim/api/serializers_/power.py new file mode 100644 index 000000000..dddd54906 --- /dev/null +++ b/netbox/dcim/api/serializers_/power.py @@ -0,0 +1,80 @@ +from rest_framework import serializers + +from dcim.choices import * +from dcim.models import PowerFeed, PowerPanel +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer +from .base import ConnectedEndpointsSerializer +from .cables import CabledObjectSerializer +from .racks import RackSerializer +from .sites import LocationSerializer, SiteSerializer + +__all__ = ( + 'PowerFeedSerializer', + 'PowerPanelSerializer', +) + + +class PowerPanelSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') + site = SiteSerializer(nested=True) + location = LocationSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + + # Related object counts + powerfeed_count = RelatedObjectCountField('powerfeeds') + + class Meta: + model = PowerPanel + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') + power_panel = PowerPanelSerializer(nested=True) + rack = RackSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=PowerFeedTypeChoices, + default=lambda: PowerFeedTypeChoices.TYPE_PRIMARY, + ) + status = ChoiceField( + choices=PowerFeedStatusChoices, + default=lambda: PowerFeedStatusChoices.STATUS_ACTIVE, + ) + supply = ChoiceField( + choices=PowerFeedSupplyChoices, + default=lambda: PowerFeedSupplyChoices.SUPPLY_AC, + ) + phase = ChoiceField( + choices=PowerFeedPhaseChoices, + default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE, + ) + tenant = TenantSerializer( + nested=True, + required=False, + allow_null=True + ) + + class Meta: + model = PowerFeed + fields = [ + 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', + 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + '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/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py new file mode 100644 index 000000000..a6754cba0 --- /dev/null +++ b/netbox/dcim/api/serializers_/racks.py @@ -0,0 +1,117 @@ +from django.utils.translation import gettext as _ +from rest_framework import serializers + +from dcim.choices import * +from dcim.constants import * +from dcim.models import Rack, RackReservation, RackRole +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from netbox.config import ConfigItem +from tenancy.api.serializers_.tenants import TenantSerializer +from users.api.serializers_.users import UserSerializer +from .sites import LocationSerializer, SiteSerializer + +__all__ = ( + 'RackElevationDetailFilterSerializer', + 'RackReservationSerializer', + 'RackRoleSerializer', + 'RackSerializer', +) + + +class RackRoleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') + + # Related object counts + rack_count = RelatedObjectCountField('racks') + + class Meta: + model = RackRole + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') + site = SiteSerializer(nested=True) + location = LocationSerializer(nested=True, required=False, allow_null=True, default=None) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + status = ChoiceField(choices=RackStatusChoices, required=False) + role = RackRoleSerializer(nested=True, required=False, allow_null=True) + type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False, allow_null=True) + facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'), + default=None) + width = ChoiceField(choices=RackWidthChoices, required=False) + outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) + + # Related object counts + device_count = RelatedObjectCountField('devices') + powerfeed_count = RelatedObjectCountField('powerfeeds') + + class Meta: + model = Rack + fields = [ + 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', + 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight', 'weight_unit', + '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 RackReservationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') + rack = RackSerializer(nested=True) + user = UserSerializer(nested=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = RackReservation + fields = [ + '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): + q = serializers.CharField( + required=False, + default=None + ) + face = serializers.ChoiceField( + choices=DeviceFaceChoices, + default=DeviceFaceChoices.FACE_FRONT + ) + render = serializers.ChoiceField( + choices=RackElevationDetailRenderChoices, + default=RackElevationDetailRenderChoices.RENDER_JSON + ) + unit_width = serializers.IntegerField( + default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH') + ) + unit_height = serializers.IntegerField( + default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') + ) + legend_width = serializers.IntegerField( + default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + ) + margin_width = serializers.IntegerField( + default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH + ) + exclude = serializers.IntegerField( + required=False, + default=None + ) + expand_devices = serializers.BooleanField( + required=False, + default=True + ) + include_images = serializers.BooleanField( + required=False, + default=True + ) diff --git a/netbox/dcim/api/serializers_/rackunits.py b/netbox/dcim/api/serializers_/rackunits.py new file mode 100644 index 000000000..1f5306718 --- /dev/null +++ b/netbox/dcim/api/serializers_/rackunits.py @@ -0,0 +1,31 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dcim.choices import * +from netbox.api.fields import ChoiceField +from .devices import DeviceSerializer + +__all__ = ( + 'RackUnitSerializer', +) + + +class RackUnitSerializer(serializers.Serializer): + """ + A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. + """ + id = serializers.DecimalField( + max_digits=4, + decimal_places=1, + read_only=True + ) + name = serializers.CharField(read_only=True) + face = ChoiceField(choices=DeviceFaceChoices, read_only=True) + device = DeviceSerializer(nested=True, read_only=True) + occupied = serializers.BooleanField(read_only=True) + display = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(OpenApiTypes.STR) + def get_display(self, obj): + return obj['name'] diff --git a/netbox/dcim/api/serializers_/roles.py b/netbox/dcim/api/serializers_/roles.py new file mode 100644 index 000000000..41f8f377d --- /dev/null +++ b/netbox/dcim/api/serializers_/roles.py @@ -0,0 +1,43 @@ +from rest_framework import serializers + +from dcim.models import DeviceRole, InventoryItemRole +from extras.api.serializers_.configtemplates import ConfigTemplateSerializer +from netbox.api.fields import RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer + +__all__ = ( + 'DeviceRoleSerializer', + 'InventoryItemRoleSerializer', +) + + +class DeviceRoleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') + config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) + + # Related object counts + device_count = RelatedObjectCountField('devices') + virtualmachine_count = RelatedObjectCountField('virtual_machines') + + class Meta: + model = DeviceRole + fields = [ + '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 InventoryItemRoleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') + + # Related object counts + inventoryitem_count = RelatedObjectCountField('inventory_items') + + class Meta: + model = InventoryItemRole + fields = [ + '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') diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py new file mode 100644 index 000000000..6fb3811ba --- /dev/null +++ b/netbox/dcim/api/serializers_/sites.py @@ -0,0 +1,98 @@ +from rest_framework import serializers +from timezone_field.rest_framework import TimeZoneSerializerField + +from dcim.choices import * +from dcim.models import Location, Region, Site, SiteGroup +from ipam.api.serializers_.asns import ASNSerializer +from ipam.models import ASN +from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField +from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer +from ..nested_serializers import * + +__all__ = ( + 'LocationSerializer', + 'RegionSerializer', + 'SiteGroupSerializer', + 'SiteSerializer', +) + + +class RegionSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') + parent = NestedRegionSerializer(required=False, allow_null=True, default=None) + site_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Region + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') + parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) + site_count = serializers.IntegerField(read_only=True) + + class Meta: + model = SiteGroup + fields = [ + '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): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') + status = ChoiceField(choices=SiteStatusChoices, required=False) + region = RegionSerializer(nested=True, required=False, allow_null=True) + group = SiteGroupSerializer(nested=True, required=False, allow_null=True) + tenant = TenantSerializer(required=False, allow_null=True) + time_zone = TimeZoneSerializerField(required=False, allow_null=True) + asns = SerializedPKRelatedField( + queryset=ASN.objects.all(), + serializer=ASNSerializer, + nested=True, + required=False, + many=True + ) + + # Related object counts + circuit_count = RelatedObjectCountField('circuit_terminations') + device_count = RelatedObjectCountField('devices') + prefix_count = RelatedObjectCountField('prefixes') + rack_count = RelatedObjectCountField('racks') + vlan_count = RelatedObjectCountField('vlans') + virtualmachine_count = RelatedObjectCountField('virtual_machines') + + class Meta: + model = Site + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags', + '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') + + +class LocationSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') + site = SiteSerializer(nested=True) + parent = NestedLocationSerializer(required=False, allow_null=True) + status = ChoiceField(choices=LocationStatusChoices, required=False) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + rack_count = serializers.IntegerField(read_only=True) + device_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Location + fields = [ + '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') diff --git a/netbox/dcim/api/serializers_/virtualchassis.py b/netbox/dcim/api/serializers_/virtualchassis.py new file mode 100644 index 000000000..5a5917119 --- /dev/null +++ b/netbox/dcim/api/serializers_/virtualchassis.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from dcim.models import VirtualChassis +from netbox.api.serializers import NetBoxModelSerializer +from ..nested_serializers import * + +__all__ = ( + 'VirtualChassisSerializer', +) + + +class VirtualChassisSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') + master = NestedDeviceSerializer(required=False, allow_null=True, default=None) + members = NestedDeviceSerializer(many=True, read_only=True) + + # Counter fields + member_count = serializers.IntegerField(read_only=True) + + class Meta: + model = VirtualChassis + fields = [ + 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'member_count', 'members', + ] + brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8a674656d..d6ddd466b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -7,7 +7,6 @@ from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet -from circuits.models import Circuit from dcim import filtersets from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.models import * @@ -18,10 +17,8 @@ from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin -from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model from utilities.query_functions import CollateAsChar -from utilities.utils import count_related from . import serializers from .exceptions import MissingFilterException @@ -60,16 +57,16 @@ class PathEndpointMixin(object): # Serialize path objects, iterating over each three-tuple in the path for near_ends, cable, far_ends in obj.trace(): if near_ends: - serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX) - near_ends = serializer_a(near_ends, many=True, context={'request': request}).data + serializer_a = get_serializer_for_model(near_ends[0]) + near_ends = serializer_a(near_ends, nested=True, many=True, context={'request': request}).data else: # Path is split; stop here break if cable: cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data if far_ends: - serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX) - far_ends = serializer_b(far_ends, many=True, context={'request': request}).data + serializer_b = get_serializer_for_model(far_ends[0]) + far_ends = serializer_b(far_ends, nested=True, many=True, context={'request': request}).data path.append((near_ends, cable, far_ends)) @@ -514,7 +511,10 @@ class CableTerminationViewSet(NetBoxModelViewSet): # class VirtualChassisViewSet(NetBoxModelViewSet): - queryset = VirtualChassis.objects.all() + queryset = VirtualChassis.objects.prefetch_related( + # Prefetch related object for the display of unnamed devices + 'master__virtual_chassis', + ) serializer_class = serializers.VirtualChassisSerializer filterset_class = filtersets.VirtualChassisFilterSet diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 2ba24e0aa..b00784265 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -889,7 +889,10 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_8GFC_SFP_PLUS = '8gfc-sfpp' TYPE_16GFC_SFP_PLUS = '16gfc-sfpp' TYPE_32GFC_SFP28 = '32gfc-sfp28' + TYPE_32GFC_SFP_PLUS = '32gfc-sfpp' TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp' + TYPE_64GFC_SFP_DD = '64gfc-sfpdd' + TYPE_64GFC_SFP_PLUS = '64gfc-sfpp' TYPE_128GFC_QSFP28 = '128gfc-qsfp28' # InfiniBand @@ -1058,7 +1061,10 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'), (TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'), (TYPE_32GFC_SFP28, 'SFP28 (32GFC)'), + (TYPE_32GFC_SFP_PLUS, 'SFP+ (32GFC)'), (TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'), + (TYPE_64GFC_SFP_DD, 'SFP-DD (64GFC)'), + (TYPE_64GFC_SFP_PLUS, 'SFP+ (64GFC)'), (TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'), ) ), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6b1611694..aa8a68296 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -18,11 +18,12 @@ from tenancy.models import * from utilities.choices import ColorChoices from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, - TreeNodeMultipleChoiceFilter, + NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from vpn.models import L2VPN from wireless.choices import WirelessRoleChoices, WirelessChannelChoices +from wireless.models import WirelessLAN, WirelessLink from .choices import * from .constants import * from .models import * @@ -89,10 +90,23 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): to_field_name='slug', label=_('Parent region (slug)'), ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Region (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Region (slug)'), + ) class Meta: model = Region - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): @@ -106,10 +120,23 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): to_field_name='slug', label=_('Parent site group (slug)'), ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Site group (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Site group (slug)'), + ) class Meta: model = SiteGroup - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -152,12 +179,11 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe queryset=ASN.objects.all(), label=_('AS (ID)'), ) + time_zone = MultiValueCharFilter() class Meta: model = Site - fields = ( - 'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description' - ) + fields = ('id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -214,13 +240,23 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM to_field_name='slug', label=_('Site (slug)'), ) - parent_id = TreeNodeMultipleChoiceFilter( + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=Location.objects.all(), + label=_('Parent location (ID)'), + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=Location.objects.all(), + to_field_name='slug', + label=_('Parent location (slug)'), + ) + ancestor_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='parent', lookup_expr='in', label=_('Location (ID)'), ) - parent = TreeNodeMultipleChoiceFilter( + ancestor = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='parent', lookup_expr='in', @@ -234,7 +270,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM class Meta: model = Location - fields = ['id', 'name', 'slug', 'status', 'description'] + fields = ('id', 'name', 'slug', 'status', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -249,7 +285,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = RackRole - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ('id', 'name', 'slug', 'color', 'description') class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -328,10 +364,10 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe class Meta: model = Rack - fields = [ + fields = ( 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', - ] + ) def search(self, queryset, name, value): if not value.strip(): @@ -411,10 +447,14 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='username', label=_('User (name)'), ) + unit = NumericArrayFilter( + field_name='units', + lookup_expr='contains' + ) class Meta: model = RackReservation - fields = ['id', 'created', 'description'] + fields = ('id', 'created', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -431,7 +471,7 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) class Meta: model = Manufacturer - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class DeviceTypeFilterSet(NetBoxModelFilterSet): @@ -502,10 +542,22 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): class Meta: model = DeviceType - fields = [ + fields = ( 'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'description', - ] + + # Counters + 'console_port_template_count', + 'console_server_port_template_count', + 'power_port_template_count', + 'power_outlet_template_count', + 'interface_template_count', + 'front_port_template_count', + 'rear_port_template_count', + 'device_bay_template_count', + 'module_bay_template_count', + 'inventory_item_template_count', + ) def search(self, queryset, name, value): if not value.strip(): @@ -599,7 +651,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet): class Meta: model = ModuleType - fields = ['id', 'model', 'part_number', 'weight', 'weight_unit', 'description'] + fields = ('id', 'model', 'part_number', 'weight', 'weight_unit', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -639,12 +691,15 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet): method='search', label=_('Search'), ) - devicetype_id = django_filters.ModelMultipleChoiceFilter( + device_type_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), field_name='device_type_id', label=_('Device type (ID)'), ) + # TODO: Remove in v4.1 + devicetype_id = device_type_id + def search(self, queryset, name, value): if not value.strip(): return queryset @@ -655,32 +710,35 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet): class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet): - moduletype_id = django_filters.ModelMultipleChoiceFilter( + module_type_id = django_filters.ModelMultipleChoiceFilter( queryset=ModuleType.objects.all(), field_name='module_type_id', label=_('Module type (ID)'), ) + # TODO: Remove in v4.1 + moduletype_id = module_type_id + class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate - fields = ['id', 'name', 'type', 'description'] + fields = ('id', 'name', 'label', 'type', 'description') class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'name', 'type', 'description'] + fields = ('id', 'name', 'label', 'type', 'description') class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate - fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description'] + fields = ('id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -688,10 +746,14 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType choices=PowerOutletFeedLegChoices, null_value=None ) + power_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=PowerPortTemplate.objects.all(), + label=_('Power port (ID)'), + ) class Meta: model = PowerOutletTemplate - fields = ['id', 'name', 'type', 'feed_leg', 'description'] + fields = ('id', 'name', 'label', 'type', 'feed_leg', 'description') class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -715,7 +777,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo class Meta: model = InterfaceTemplate - fields = ['id', 'name', 'type', 'enabled', 'mgmt_only', 'description'] + fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description') class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -723,10 +785,13 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo choices=PortTypeChoices, null_value=None ) + rear_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=RearPort.objects.all() + ) class Meta: model = FrontPortTemplate - fields = ['id', 'name', 'type', 'color', 'description'] + fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description') class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -737,21 +802,21 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom class Meta: model = RearPortTemplate - fields = ['id', 'name', 'type', 'color', 'positions', 'description'] + fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description') class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: model = ModuleBayTemplate - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'label', 'position', 'description') class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: model = DeviceBayTemplate - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'label', 'description') class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): @@ -784,7 +849,7 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo class Meta: model = InventoryItemTemplate - fields = ['id', 'name', 'label', 'part_id', 'description'] + fields = ('id', 'name', 'label', 'part_id', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -805,7 +870,7 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = DeviceRole - fields = ['id', 'name', 'slug', 'color', 'vm_role', 'description'] + fields = ('id', 'name', 'slug', 'color', 'vm_role', 'description') class PlatformFilterSet(OrganizationalModelFilterSet): @@ -831,7 +896,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet): class Meta: model = Platform - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') @extend_schema_field(OpenApiTypes.STR) def get_for_device_type(self, queryset, name, value): @@ -943,6 +1008,11 @@ class DeviceFilterSet( queryset=Rack.objects.all(), label=_('Rack (ID)'), ) + parent_bay_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent_bay', + queryset=DeviceBay.objects.all(), + label=_('Parent bay (ID)'), + ) cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), label=_('VM cluster (ID)'), @@ -1032,10 +1102,22 @@ class DeviceFilterSet( class Meta: model = Device - fields = [ + fields = ( 'id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority', 'description', - ] + + # Counters + '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', + ) def search(self, queryset, name, value): if not value.strip(): @@ -1098,24 +1180,29 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, Prim device_id = django_filters.ModelMultipleChoiceFilter( field_name='device', queryset=Device.objects.all(), - label='VDC (ID)', + label=_('VDC (ID)') ) device = django_filters.ModelMultipleChoiceFilter( field_name='device', queryset=Device.objects.all(), - label='Device model', + label=_('Device model') + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interfaces', + queryset=Interface.objects.all(), + label=_('Interface (ID)') ) status = django_filters.MultipleChoiceFilter( choices=VirtualDeviceContextStatusChoices ) has_primary_ip = django_filters.BooleanFilter( method='_has_primary_ip', - label='Has a primary IP', + label=_('Has a primary IP') ) class Meta: model = VirtualDeviceContext - fields = ['id', 'device', 'name', 'description'] + fields = ('id', 'device', 'name', 'identifier', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1181,7 +1268,7 @@ class ModuleFilterSet(NetBoxModelFilterSet): class Meta: model = Module - fields = ['id', 'status', 'asset_tag', 'description'] + fields = ('id', 'status', 'asset_tag', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1325,6 +1412,10 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet): class CabledObjectFilterSet(django_filters.FilterSet): + cable_id = django_filters.ModelMultipleChoiceFilter( + queryset=Cable.objects.all(), + label=_('Cable (ID)'), + ) cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', @@ -1366,7 +1457,7 @@ class ConsolePortFilterSet( class Meta: model = ConsolePort - fields = ['id', 'name', 'label', 'description', 'cable_end'] + fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end') class ConsoleServerPortFilterSet( @@ -1382,7 +1473,7 @@ class ConsoleServerPortFilterSet( class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'label', 'description', 'cable_end'] + fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end') class PowerPortFilterSet( @@ -1398,7 +1489,9 @@ class PowerPortFilterSet( class Meta: model = PowerPort - fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end', + ) class PowerOutletFilterSet( @@ -1415,10 +1508,16 @@ class PowerOutletFilterSet( choices=PowerOutletFeedLegChoices, null_value=None ) + power_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=PowerPort.objects.all(), + label=_('Power port (ID)'), + ) class Meta: model = PowerOutlet - fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'feed_leg', 'description', 'mark_connected', 'cable_end', + ) class CommonInterfaceFilterSet(django_filters.FilterSet): @@ -1533,27 +1632,37 @@ class InterfaceFilterSet( vdc_id = django_filters.ModelMultipleChoiceFilter( field_name='vdcs', queryset=VirtualDeviceContext.objects.all(), - label='Virtual Device Context', + label=_('Virtual Device Context') ) vdc_identifier = django_filters.ModelMultipleChoiceFilter( field_name='vdcs__identifier', queryset=VirtualDeviceContext.objects.all(), to_field_name='identifier', - label='Virtual Device Context (Identifier)', + label=_('Virtual Device Context (Identifier)') ) vdc = django_filters.ModelMultipleChoiceFilter( field_name='vdcs__name', queryset=VirtualDeviceContext.objects.all(), to_field_name='name', - label='Virtual Device Context', + label=_('Virtual Device Context') + ) + wireless_lan_id = django_filters.ModelMultipleChoiceFilter( + field_name='wireless_lans', + queryset=WirelessLAN.objects.all(), + label=_('Wireless LAN') + ) + wireless_link_id = django_filters.ModelMultipleChoiceFilter( + queryset=WirelessLink.objects.all(), + label=_('Wireless link') ) class Meta: model = Interface - fields = [ + fields = ( 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', - 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end', - ] + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', + 'cable_id', 'cable_end', + ) def filter_virtual_chassis_member(self, queryset, name, value): try: @@ -1582,10 +1691,15 @@ class FrontPortFilterSet( choices=PortTypeChoices, null_value=None ) + rear_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=RearPort.objects.all() + ) class Meta: model = FrontPort - fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end', + ) class RearPortFilterSet( @@ -1600,21 +1714,38 @@ class RearPortFilterSet( class Meta: model = RearPort - fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', + ) class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): + installed_module_id = django_filters.ModelMultipleChoiceFilter( + field_name='installed_module', + queryset=ModuleBay.objects.all(), + label=_('Installed module (ID)'), + ) class Meta: model = ModuleBay - fields = ['id', 'name', 'label', 'description'] + fields = ('id', 'name', 'label', 'position', 'description') class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): + installed_device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label=_('Installed device (ID)'), + ) + installed_device = django_filters.ModelMultipleChoiceFilter( + field_name='installed_device__name', + queryset=Device.objects.all(), + to_field_name='name', + label=_('Installed device (name)'), + ) class Meta: model = DeviceBay - fields = ['id', 'name', 'label', 'description'] + fields = ('id', 'name', 'label', 'description') class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): @@ -1650,7 +1781,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): class Meta: model = InventoryItem - fields = ['id', 'name', 'label', 'part_id', 'asset_tag', 'discovered'] + fields = ('id', 'name', 'label', 'part_id', 'asset_tag', 'description', 'discovered') def search(self, queryset, name, value): if not value.strip(): @@ -1669,7 +1800,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = InventoryItemRole - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ('id', 'name', 'slug', 'color', 'description') class VirtualChassisFilterSet(NetBoxModelFilterSet): @@ -1734,7 +1865,7 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet): class Meta: model = VirtualChassis - fields = ['id', 'domain', 'name', 'description'] + fields = ('id', 'domain', 'name', 'description', 'member_count') def search(self, queryset, name, value): if not value.strip(): @@ -1839,7 +1970,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): class Meta: model = Cable - fields = ['id', 'label', 'length', 'length_unit', 'description'] + fields = ('id', 'label', 'length', 'length_unit', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1917,12 +2048,12 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): return self.filter_by_termination_object(queryset, CircuitTermination, value) -class CableTerminationFilterSet(BaseFilterSet): +class CableTerminationFilterSet(ChangeLoggedModelFilterSet): termination_type = ContentTypeFilter() class Meta: model = CableTermination - fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id'] + fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id') class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): @@ -1971,7 +2102,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class Meta: model = PowerPanel - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -2037,10 +2168,10 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi class Meta: model = PowerFeed - fields = [ - 'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end', - 'description', - ] + fields = ( + 'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', + 'available_power', 'mark_connected', 'cable_end', 'description', + ) def search(self, queryset, name, value): if not value.strip(): @@ -2099,18 +2230,18 @@ class ConsoleConnectionFilterSet(ConnectionFilterSet): class Meta: model = ConsolePort - fields = ['name'] + fields = ('name',) class PowerConnectionFilterSet(ConnectionFilterSet): class Meta: model = PowerPort - fields = ['name'] + fields = ('name',) class InterfaceConnectionFilterSet(ConnectionFilterSet): class Meta: model = Interface - fields = [] + fields = tuple() diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 89793528d..e35055851 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -754,7 +754,7 @@ class DeviceFilterForm( ) has_oob_ip = forms.NullBooleanField( required=False, - label='Has an OOB IP', + label=_('Has an OOB IP'), widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index cba345941..f8a61a794 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -9,7 +9,7 @@ from django.dispatch import Signal from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from core.models import ContentType +from core.models import ObjectType from dcim.choices import * from dcim.constants import * from dcim.fields import PathField @@ -481,13 +481,13 @@ class CablePath(models.Model): def origin_type(self): if self.path: ct_id, _ = decompile_path_node(self.path[0][0]) - return ContentType.objects.get_for_id(ct_id) + return ObjectType.objects.get_for_id(ct_id) @property def destination_type(self): if self.is_complete: ct_id, _ = decompile_path_node(self.path[-1][0]) - return ContentType.objects.get_for_id(ct_id) + return ObjectType.objects.get_for_id(ct_id) @property def path_objects(self): @@ -594,7 +594,7 @@ class CablePath(models.Model): # Step 6: Determine the far-end terminations if isinstance(links[0], Cable): - termination_type = ContentType.objects.get_for_model(terminations[0]) + termination_type = ObjectType.objects.get_for_model(terminations[0]) local_cable_terminations = CableTermination.objects.filter( termination_type=termination_type, termination_id__in=[t.pk for t in terminations] @@ -747,7 +747,7 @@ class CablePath(models.Model): # Prefetch path objects using one query per model type. Prefetch related devices where appropriate. prefetched = {} for ct_id, object_ids in to_prefetch.items(): - model_class = ContentType.objects.get_for_id(ct_id).model_class() + model_class = ObjectType.objects.get_for_id(ct_id).model_class() queryset = model_class.objects.filter(pk__in=object_ids) if hasattr(model_class, 'device'): queryset = queryset.prefetch_related('device') @@ -774,7 +774,7 @@ class CablePath(models.Model): """ Return all Cable IDs within the path. """ - cable_ct = ContentType.objects.get_for_model(Cable).pk + cable_ct = ObjectType.objects.get_for_model(Cable).pk cable_ids = [] for node in self._nodes: diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 5e773364a..4f221fe16 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -229,15 +229,16 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): 'manufacturer': self.manufacturer.name, 'model': self.model, 'slug': self.slug, + 'description': self.description, 'default_platform': self.default_platform.name if self.default_platform else None, 'part_number': self.part_number, 'u_height': float(self.u_height), 'is_full_depth': self.is_full_depth, 'subdevice_role': self.subdevice_role, 'airflow': self.airflow, - 'comments': self.comments, 'weight': float(self.weight) if self.weight is not None else None, 'weight_unit': self.weight_unit, + 'comments': self.comments, } # Component templates @@ -415,9 +416,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): 'manufacturer': self.manufacturer.name, 'model': self.model, 'part_number': self.part_number, - 'comments': self.comments, + 'description': self.description, 'weight': float(self.weight) if self.weight is not None else None, 'weight_unit': self.weight_unit, + 'comments': self.comments, } # Component templates @@ -815,20 +817,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() diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index d4c9641b6..98dcfcb3c 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -210,6 +210,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): linkify=True, verbose_name=_('Type') ) + platform = tables.Column( + linkify=True, + verbose_name=_('Platform') + ) primary_ip = tables.Column( linkify=True, order_by=('primary_ip4', 'primary_ip6'), @@ -294,7 +298,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): model = models.Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type', - 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', + 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', 'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated', 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 b255c283e..1e46d66ac 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -64,21 +64,32 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - regions = ( + parent_regions = ( Region(name='Region 1', slug='region-1', description='foobar1'), Region(name='Region 2', slug='region-2', description='foobar2'), Region(name='Region 3', slug='region-3', description='foobar3'), ) + for region in parent_regions: + region.save() + + regions = ( + Region(name='Region 1A', slug='region-1a', parent=parent_regions[0]), + Region(name='Region 1B', slug='region-1b', parent=parent_regions[0]), + Region(name='Region 2A', slug='region-2a', parent=parent_regions[1]), + Region(name='Region 2B', slug='region-2b', parent=parent_regions[1]), + Region(name='Region 3A', slug='region-3a', parent=parent_regions[2]), + Region(name='Region 3B', slug='region-3b', parent=parent_regions[2]), + ) for region in regions: region.save() child_regions = ( - Region(name='Region 1A', slug='region-1a', parent=regions[0]), - Region(name='Region 1B', slug='region-1b', parent=regions[0]), - Region(name='Region 2A', slug='region-2a', parent=regions[1]), - Region(name='Region 2B', slug='region-2b', parent=regions[1]), - Region(name='Region 3A', slug='region-3a', parent=regions[2]), - Region(name='Region 3B', slug='region-3b', parent=regions[2]), + Region(name='Region 1A1', slug='region-1a1', parent=regions[0]), + Region(name='Region 1B1', slug='region-1b1', parent=regions[1]), + Region(name='Region 2A1', slug='region-2a1', parent=regions[2]), + Region(name='Region 2B1', slug='region-2b1', parent=regions[3]), + Region(name='Region 3A1', slug='region-3a1', parent=regions[4]), + Region(name='Region 3B1', slug='region-3b1', parent=regions[5]), ) for region in child_regions: region.save() @@ -100,12 +111,19 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - parent_regions = Region.objects.filter(parent__isnull=True)[:2] - params = {'parent_id': [parent_regions[0].pk, parent_regions[1].pk]} + regions = Region.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [regions[0].pk, regions[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'parent': [parent_regions[0].slug, parent_regions[1].slug]} + params = {'parent': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_ancestor(self): + regions = Region.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'ancestor': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = SiteGroup.objects.all() @@ -114,24 +132,35 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - sitegroups = ( + parent_groups = ( SiteGroup(name='Site Group 1', slug='site-group-1', description='foobar1'), SiteGroup(name='Site Group 2', slug='site-group-2', description='foobar2'), SiteGroup(name='Site Group 3', slug='site-group-3', description='foobar3'), ) - for sitegroup in sitegroups: - sitegroup.save() + for site_group in parent_groups: + site_group.save() - child_sitegroups = ( - SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=sitegroups[0]), - SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=sitegroups[0]), - SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=sitegroups[1]), - SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=sitegroups[1]), - SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=sitegroups[2]), - SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=sitegroups[2]), + groups = ( + SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=parent_groups[0]), + SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=parent_groups[0]), + SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]), + SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]), + SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]), + SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2]), ) - for sitegroup in child_sitegroups: - sitegroup.save() + for site_group in groups: + site_group.save() + + child_groups = ( + SiteGroup(name='Site Group 1A1', slug='site-group-1a1', parent=groups[0]), + SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]), + SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]), + SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]), + SiteGroup(name='Site Group 3A1', slug='site-group-3a1', parent=groups[4]), + SiteGroup(name='Site Group 3B1', slug='site-group-3b1', parent=groups[5]), + ) + for site_group in child_groups: + site_group.save() def test_q(self): params = {'q': 'foobar1'} @@ -150,16 +179,24 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - parent_sitegroups = SiteGroup.objects.filter(parent__isnull=True)[:2] - params = {'parent_id': [parent_sitegroups[0].pk, parent_sitegroups[1].pk]} + site_groups = SiteGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [site_groups[0].pk, site_groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'parent': [parent_sitegroups[0].slug, parent_sitegroups[1].slug]} + params = {'parent': [site_groups[0].slug, site_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_ancestor(self): + site_groups = SiteGroup.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'ancestor': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Site.objects.all() filterset = SiteFilterSet + ignore_fields = ('physical_address', 'shipping_address') @classmethod def setUpTestData(cls): @@ -314,21 +351,29 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): Site.objects.bulk_create(sites) parent_locations = ( - Location(name='Parent Location 1', slug='parent-location-1', site=sites[0]), - Location(name='Parent Location 2', slug='parent-location-2', site=sites[1]), - Location(name='Parent Location 3', slug='parent-location-3', site=sites[2]), + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), ) for location in parent_locations: location.save() locations = ( - Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='foobar1'), - Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='foobar2'), - Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='foobar3'), + Location(name='Location 1A', slug='location-1a', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='foobar1'), + Location(name='Location 2A', slug='location-2a', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='foobar2'), + Location(name='Location 3A', slug='location-3a', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='foobar3'), ) for location in locations: location.save() + child_locations = ( + Location(name='Location 1A1', slug='location-1a1', site=sites[0], parent=locations[0]), + Location(name='Location 2A1', slug='location-2a1', site=sites[1], parent=locations[1]), + Location(name='Location 3A1', slug='location-3a1', site=sites[2], parent=locations[2]), + ) + for location in child_locations: + location.save() + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -352,31 +397,38 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'region': [regions[0].slug, regions[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_site_group(self): site_groups = SiteGroup.objects.all()[:2] params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'site': [sites[0].slug, sites[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_parent(self): - parent_groups = Location.objects.filter(name__startswith='Parent')[:2] - params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + locations = Location.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [locations[0].pk, locations[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + params = {'parent': [locations[0].slug, locations[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_ancestor(self): + locations = Location.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'ancestor': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackRole.objects.all() @@ -416,6 +468,7 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): class RackTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Rack.objects.all() filterset = RackFilterSet + ignore_fields = ('units',) @classmethod def setUpTestData(cls): @@ -675,6 +728,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackReservation.objects.all() filterset = RackReservationFilterSet + ignore_fields = ('units',) @classmethod def setUpTestData(cls): @@ -838,6 +892,7 @@ class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceType.objects.all() filterset = DeviceTypeFilterSet + ignore_fields = ('front_image', 'rear_image') @classmethod def setUpTestData(cls): @@ -1829,6 +1884,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Device.objects.all() filterset = DeviceFilterSet + ignore_fields = ('local_context_data', 'oob_ip', 'primary_ip4', 'primary_ip6', 'vc_master_for') @classmethod def setUpTestData(cls): @@ -2281,6 +2337,7 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Module.objects.all() filterset = ModuleFilterSet + ignore_fields = ('local_context_data',) @classmethod def setUpTestData(cls): @@ -3178,6 +3235,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() filterset = InterfaceFilterSet + ignore_fields = ('tagged_vlans', 'untagged_vlan', 'vdcs') @classmethod def setUpTestData(cls): @@ -5281,6 +5339,7 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VirtualDeviceContext.objects.all() filterset = VirtualDeviceContextFilterSet + ignore_fields = ('primary_ip4', 'primary_ip6') @classmethod def setUpTestData(cls): @@ -5350,15 +5409,22 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): VirtualDeviceContext.objects.bulk_create(vdcs) interfaces = ( - Interface(device=devices[0], name='Interface 1', type='virtual'), - Interface(device=devices[0], name='Interface 2', type='virtual'), + Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[1], name='Interface 4', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[2], name='Interface 5', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[2], name='Interface 6', type=InterfaceTypeChoices.TYPE_VIRTUAL), ) Interface.objects.bulk_create(interfaces) - interfaces[0].vdcs.set([vdcs[0]]) interfaces[1].vdcs.set([vdcs[1]]) + interfaces[2].vdcs.set([vdcs[2]]) + interfaces[3].vdcs.set([vdcs[3]]) + interfaces[4].vdcs.set([vdcs[4]]) + interfaces[5].vdcs.set([vdcs[5]]) - addresses = ( + ip_addresses = ( IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), IPAddress(assigned_object=None, address='10.1.1.3/24'), @@ -5366,13 +5432,12 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'), IPAddress(assigned_object=None, address='2001:db8::3/64'), ) - IPAddress.objects.bulk_create(addresses) - - vdcs[0].primary_ip4 = addresses[0] - vdcs[0].primary_ip6 = addresses[3] + IPAddress.objects.bulk_create(ip_addresses) + vdcs[0].primary_ip4 = ip_addresses[0] + vdcs[0].primary_ip6 = ip_addresses[3] vdcs[0].save() - vdcs[1].primary_ip4 = addresses[1] - vdcs[1].primary_ip6 = addresses[4] + vdcs[1].primary_ip4 = ip_addresses[1] + vdcs[1].primary_ip6 = ip_addresses[4] vdcs[1].save() def test_q(self): @@ -5380,8 +5445,11 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_device(self): - params = {'device': ['Device 1', 'Device 2']} + devices = Device.objects.filter(name__in=['Device 1', 'Device 2']) + params = {'device': [devices[0].name, devices[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_status(self): params = {'status': ['active']} @@ -5391,10 +5459,10 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_device_id(self): - devices = Device.objects.filter(name__in=['Device 1', 'Device 2']) - params = {'device_id': [devices[0].pk, devices[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_interface(self): + interfaces = Interface.objects.filter(name__in=['Interface 1', 'Interface 3']) + params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_has_primary_ip(self): params = {'has_primary_ip': True} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index d56bf0741..1a5cc8435 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,8 +1,8 @@ -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.test import TestCase from circuits.models import * +from core.models import ObjectType from dcim.choices import * from dcim.models import * from extras.models import CustomField @@ -293,8 +293,8 @@ class DeviceTestCase(TestCase): # Create a CustomField with a default value & assign it to all component models cf1 = CustomField.objects.create(name='cf1', default='foo') - cf1.content_types.set( - ContentType.objects.filter(app_label='dcim', model__in=[ + cf1.object_types.set( + ObjectType.objects.filter(app_label='dcim', model__in=[ 'consoleport', 'consoleserverport', 'powerport', @@ -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/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 53d7f3d34..e9e5a557b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -3,7 +3,6 @@ from zoneinfo import ZoneInfo import yaml from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse from netaddr import EUI @@ -2982,7 +2981,6 @@ class CableTestCase( tags = create_tags('Alpha', 'Bravo', 'Charlie') - interface_ct = ContentType.objects.get_for_model(Interface) cls.form_data = { # TODO: Revisit this limitation # Changing terminations not supported when editing an existing Cable diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b5a90ced3..93e5f04dc 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1079,7 +1079,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): tab = ViewTab( label=_('Inventory Items'), badge=lambda obj: obj.inventory_item_template_count, - permission='dcim.view_invenotryitemtemplate', + permission='dcim.view_inventoryitemtemplate', weight=590, hide_if_empty=True ) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 6cd3a245e..09f247929 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,13 +1,12 @@ -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 drf_spectacular.utils import extend_schema_field from rest_framework.fields import Field from rest_framework.serializers import ValidationError +from core.models import ObjectType from extras.choices import CustomFieldTypeChoices from extras.models import CustomField -from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model @@ -25,8 +24,8 @@ class CustomFieldDefaultValues: self.model = serializer_field.parent.Meta.model # Retrieve the CustomFields for the parent model - content_type = ContentType.objects.get_for_model(self.model) - fields = CustomField.objects.filter(content_types=content_type) + object_type = ObjectType.objects.get_for_model(self.model) + fields = CustomField.objects.filter(object_types=object_type) # Populate the default value for each CustomField value = {} @@ -47,8 +46,8 @@ class CustomFieldsDataField(Field): Cache CustomFields assigned to this model to avoid redundant database queries """ if not hasattr(self, '_custom_fields'): - content_type = ContentType.objects.get_for_model(self.parent.Meta.model) - self._custom_fields = CustomField.objects.filter(content_types=content_type) + object_type = ObjectType.objects.get_for_model(self.parent.Meta.model) + self._custom_fields = CustomField.objects.filter(object_types=object_type) return self._custom_fields def to_representation(self, obj): @@ -58,11 +57,11 @@ class CustomFieldsDataField(Field): for cf in self._get_custom_fields(): value = cf.deserialize(obj.get(cf.name)) if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: - serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) - value = serializer(value, context=self.parent.context).data + serializer = get_serializer_for_model(cf.related_object_type.model_class()) + value = serializer(value, nested=True, context=self.parent.context).data elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) - value = serializer(value, many=True, context=self.parent.context).data + serializer = get_serializer_for_model(cf.related_object_type.model_class()) + value = serializer(value, nested=True, many=True, context=self.parent.context).data data[cf.name] = value return data @@ -80,12 +79,9 @@ class CustomFieldsDataField(Field): CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT ): - serializer_class = get_serializer_for_model( - model=cf.object_type.model_class(), - prefix=NESTED_SERIALIZER_PREFIX - ) + serializer_class = get_serializer_for_model(cf.related_object_type.model_class()) many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT - serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context) + serializer = serializer_class(data=data[cf.name], nested=True, many=many, context=self.parent.context) if serializer.is_valid(): data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id'] else: diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py index 1737ff9f8..aafdf32d4 100644 --- a/netbox/extras/api/mixins.py +++ b/netbox/extras/api/mixins.py @@ -5,7 +5,7 @@ from rest_framework.response import Response from rest_framework.status import HTTP_400_BAD_REQUEST from netbox.api.renderers import TextRenderer -from .nested_serializers import NestedConfigTemplateSerializer +from .serializers import ConfigTemplateSerializer __all__ = ( 'ConfigContextQuerySetMixin', @@ -52,7 +52,7 @@ class ConfigTemplateRenderMixin: if request.accepted_renderer.format == 'txt': return Response(output) - template_serializer = NestedConfigTemplateSerializer(configtemplate, context={'request': request}) + template_serializer = ConfigTemplateSerializer(configtemplate, nested=True, context={'request': request}) return Response({ 'configtemplate': template_serializer.data, diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 7dad95263..bd19b3184 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,659 +1,16 @@ -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 - -from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer -from core.api.serializers import JobSerializer -from core.models import ContentType -from dcim.api.nested_serializers import ( - NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, - NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, -) -from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup -from extras.choices import * -from extras.models import * -from netbox.api.exceptions import SerializerNotFound -from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField -from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer -from netbox.api.serializers.features import TaggableModelSerializer -from netbox.constants import NESTED_SERIALIZER_PREFIX -from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer -from tenancy.models import Tenant, TenantGroup -from users.api.nested_serializers import NestedUserSerializer -from utilities.api import get_serializer_for_model -from virtualization.api.nested_serializers import ( - NestedClusterGroupSerializer, NestedClusterSerializer, NestedClusterTypeSerializer, -) -from virtualization.models import Cluster, ClusterGroup, ClusterType +from .serializers_.objecttypes import * +from .serializers_.attachments import * +from .serializers_.bookmarks import * +from .serializers_.change_logging import * +from .serializers_.customfields import * +from .serializers_.customlinks import * +from .serializers_.dashboard import * +from .serializers_.events import * +from .serializers_.exporttemplates import * +from .serializers_.journaling import * +from .serializers_.configcontexts import * +from .serializers_.configtemplates import * +from .serializers_.savedfilters import * +from .serializers_.scripts import * +from .serializers_.tags import * from .nested_serializers import * - -__all__ = ( - 'BookmarkSerializer', - 'ConfigContextSerializer', - 'ConfigTemplateSerializer', - 'ContentTypeSerializer', - 'CustomFieldChoiceSetSerializer', - 'CustomFieldSerializer', - 'CustomLinkSerializer', - 'DashboardSerializer', - 'EventRuleSerializer', - 'ExportTemplateSerializer', - 'ImageAttachmentSerializer', - 'JournalEntrySerializer', - 'ObjectChangeSerializer', - 'SavedFilterSerializer', - 'ScriptDetailSerializer', - 'ScriptInputSerializer', - 'ScriptSerializer', - 'TagSerializer', - 'WebhookSerializer', -) - - -# -# Event Rules -# - -class EventRuleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail') - content_types = ContentTypeField( - queryset=ContentType.objects.with_feature('event_rules'), - many=True - ) - action_type = ChoiceField(choices=EventRuleActionChoices) - action_object_type = ContentTypeField( - queryset=ContentType.objects.with_feature('event_rules'), - ) - action_object = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = EventRule - fields = [ - 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', - '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 = 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(), - prefix=NESTED_SERIALIZER_PREFIX - ) - return serializer(instance.action_object, context=context).data - - -# -# Webhooks -# - -class WebhookSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') - - class Meta: - model = Webhook - fields = [ - 'id', 'url', 'display', 'name', 'description', 'payload_url', 'http_method', 'http_content_type', - 'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields', - 'tags', 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - -# -# Custom fields -# - -class CustomFieldSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') - content_types = ContentTypeField( - queryset=ContentType.objects.with_feature('custom_fields'), - many=True - ) - type = ChoiceField(choices=CustomFieldTypeChoices) - object_type = ContentTypeField( - queryset=ContentType.objects.all(), - required=False, - allow_null=True - ) - filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) - data_type = serializers.SerializerMethodField() - choice_set = NestedCustomFieldChoiceSetSerializer( - required=False, - allow_null=True - ) - ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False) - ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False) - - class Meta: - model = CustomField - fields = [ - 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', - 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', - '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.')) - - return value - - @extend_schema_field(OpenApiTypes.STR) - def get_data_type(self, obj): - types = CustomFieldTypeChoices - if obj.type == types.TYPE_INTEGER: - return 'integer' - if obj.type == types.TYPE_DECIMAL: - return 'decimal' - if obj.type == types.TYPE_BOOLEAN: - return 'boolean' - if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): - return 'object' - if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT): - return 'array' - return 'string' - - -class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail') - base_choices = ChoiceField( - choices=CustomFieldChoiceSetBaseChoices, - required=False - ) - extra_choices = serializers.ListField( - child=serializers.ListField( - min_length=2, - max_length=2 - ) - ) - - class Meta: - model = CustomFieldChoiceSet - fields = [ - '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') - - -# -# Custom links -# - -class CustomLinkSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') - content_types = ContentTypeField( - queryset=ContentType.objects.with_feature('custom_links'), - many=True - ) - - class Meta: - model = CustomLink - fields = [ - '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') - - -# -# Export templates -# - -class ExportTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') - content_types = ContentTypeField( - queryset=ContentType.objects.with_feature('export_templates'), - many=True - ) - data_source = NestedDataSourceSerializer( - required=False - ) - data_file = NestedDataFileSerializer( - read_only=True - ) - - class Meta: - model = ExportTemplate - fields = [ - 'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', - 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - -# -# Saved filters -# - -class SavedFilterSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') - content_types = ContentTypeField( - queryset=ContentType.objects.all(), - many=True - ) - - class Meta: - model = SavedFilter - fields = [ - 'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled', - 'shared', 'parameters', 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description') - - -# -# Bookmarks -# - -class BookmarkSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') - object_type = ContentTypeField( - queryset=ContentType.objects.with_feature('bookmarks'), - ) - object = serializers.SerializerMethodField(read_only=True) - user = NestedUserSerializer() - - class Meta: - model = Bookmark - 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): - serializer = get_serializer_for_model(instance.object, prefix=NESTED_SERIALIZER_PREFIX) - return serializer(instance.object, context={'request': self.context['request']}).data - - -# -# Tags -# - -class TagSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') - object_types = ContentTypeField( - queryset=ContentType.objects.with_feature('tags'), - many=True, - required=False - ) - - # Related object counts - tagged_items = RelatedObjectCountField('extras_taggeditem_items') - - class Meta: - model = Tag - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created', - 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description') - - -# -# Image attachments -# - -class ImageAttachmentSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') - content_type = ContentTypeField( - queryset=ContentType.objects.all() - ) - parent = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = ImageAttachment - fields = [ - '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): - - # Validate that the parent object exists - try: - data['content_type'].get_object_for_this_type(id=data['object_id']) - except ObjectDoesNotExist: - raise serializers.ValidationError( - "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) - ) - - # Enforce model validation - super().validate(data) - - return data - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_parent(self, obj): - serializer = get_serializer_for_model(obj.parent, prefix=NESTED_SERIALIZER_PREFIX) - return serializer(obj.parent, context={'request': self.context['request']}).data - - -# -# Journal entries -# - -class JournalEntrySerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') - assigned_object_type = ContentTypeField( - queryset=ContentType.objects.all() - ) - assigned_object = serializers.SerializerMethodField(read_only=True) - created_by = serializers.PrimaryKeyRelatedField( - allow_null=True, - queryset=get_user_model().objects.all(), - required=False, - default=serializers.CurrentUserDefault() - ) - kind = ChoiceField( - choices=JournalEntryKindChoices, - required=False - ) - - class Meta: - model = JournalEntry - fields = [ - '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): - - # Validate that the parent object exists - if 'assigned_object_type' in data and 'assigned_object_id' in data: - try: - data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']) - except ObjectDoesNotExist: - raise serializers.ValidationError( - f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}" - ) - - # Enforce model validation - super().validate(data) - - return data - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_assigned_object(self, instance): - serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(instance.assigned_object, context=context).data - - -# -# Config contexts -# - -class ConfigContextSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail') - regions = SerializedPKRelatedField( - queryset=Region.objects.all(), - serializer=NestedRegionSerializer, - required=False, - many=True - ) - site_groups = SerializedPKRelatedField( - queryset=SiteGroup.objects.all(), - serializer=NestedSiteGroupSerializer, - required=False, - many=True - ) - sites = SerializedPKRelatedField( - queryset=Site.objects.all(), - serializer=NestedSiteSerializer, - required=False, - many=True - ) - locations = SerializedPKRelatedField( - queryset=Location.objects.all(), - serializer=NestedLocationSerializer, - required=False, - many=True - ) - device_types = SerializedPKRelatedField( - queryset=DeviceType.objects.all(), - serializer=NestedDeviceTypeSerializer, - required=False, - many=True - ) - roles = SerializedPKRelatedField( - queryset=DeviceRole.objects.all(), - serializer=NestedDeviceRoleSerializer, - required=False, - many=True - ) - platforms = SerializedPKRelatedField( - queryset=Platform.objects.all(), - serializer=NestedPlatformSerializer, - required=False, - many=True - ) - cluster_types = SerializedPKRelatedField( - queryset=ClusterType.objects.all(), - serializer=NestedClusterTypeSerializer, - required=False, - many=True - ) - cluster_groups = SerializedPKRelatedField( - queryset=ClusterGroup.objects.all(), - serializer=NestedClusterGroupSerializer, - required=False, - many=True - ) - clusters = SerializedPKRelatedField( - queryset=Cluster.objects.all(), - serializer=NestedClusterSerializer, - required=False, - many=True - ) - tenant_groups = SerializedPKRelatedField( - queryset=TenantGroup.objects.all(), - serializer=NestedTenantGroupSerializer, - required=False, - many=True - ) - tenants = SerializedPKRelatedField( - queryset=Tenant.objects.all(), - serializer=NestedTenantSerializer, - required=False, - many=True - ) - tags = serializers.SlugRelatedField( - queryset=Tag.objects.all(), - slug_field='slug', - required=False, - many=True - ) - data_source = NestedDataSourceSerializer( - required=False - ) - data_file = NestedDataFileSerializer( - read_only=True - ) - - class Meta: - model = ConfigContext - fields = [ - 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', - 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', - 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data', - 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - -# -# Config templates -# - -class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail') - data_source = NestedDataSourceSerializer( - required=False - ) - data_file = NestedDataFileSerializer( - required=False - ) - - class Meta: - model = ConfigTemplate - fields = [ - 'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source', - 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - -# -# Scripts -# - -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(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, 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 = 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): - data = serializers.JSONField() - commit = serializers.BooleanField() - 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['script'].scheduling_enabled: - 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.")) - return value - - -# -# Change logging -# - -class ObjectChangeSerializer(BaseModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail') - user = NestedUserSerializer( - read_only=True - ) - action = ChoiceField( - choices=ObjectChangeActionChoices, - read_only=True - ) - changed_object_type = ContentTypeField( - read_only=True - ) - changed_object = serializers.SerializerMethodField( - read_only=True - ) - - class Meta: - model = ObjectChange - fields = [ - 'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', - 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data', - ] - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_changed_object(self, obj): - """ - Serialize a nested representation of the changed object. - """ - if obj.changed_object is None: - return None - - try: - serializer = get_serializer_for_model(obj.changed_object, prefix=NESTED_SERIALIZER_PREFIX) - except SerializerNotFound: - return obj.object_repr - context = { - 'request': self.context['request'] - } - data = serializer(obj.changed_object, context=context).data - - return data - - -# -# ContentTypes -# - -class ContentTypeSerializer(BaseModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail') - - class Meta: - model = ContentType - fields = ['id', 'url', 'display', 'app_label', 'model'] - - -# -# User dashboard -# - -class DashboardSerializer(serializers.ModelSerializer): - class Meta: - model = Dashboard - fields = ('layout', 'config') diff --git a/netbox/extras/api/serializers_/__init__.py b/netbox/extras/api/serializers_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/api/serializers_/attachments.py b/netbox/extras/api/serializers_/attachments.py new file mode 100644 index 000000000..bcf3a24ec --- /dev/null +++ b/netbox/extras/api/serializers_/attachments.py @@ -0,0 +1,50 @@ +from django.core.exceptions import ObjectDoesNotExist +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from core.models import ObjectType +from extras.models import ImageAttachment +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer +from utilities.api import get_serializer_for_model + +__all__ = ( + 'ImageAttachmentSerializer', +) + + +class ImageAttachmentSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') + object_type = ContentTypeField( + queryset=ObjectType.objects.all() + ) + parent = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = ImageAttachment + fields = [ + 'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image', 'image_height', + 'image_width', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'image') + + def validate(self, data): + + # Validate that the parent object exists + try: + data['object_type'].get_object_for_this_type(id=data['object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + "Invalid parent object: {} ID {}".format(data['object_type'], data['object_id']) + ) + + # Enforce model validation + super().validate(data) + + return data + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_parent(self, obj): + serializer = get_serializer_for_model(obj.parent) + context = {'request': self.context['request']} + return serializer(obj.parent, nested=True, context=context).data diff --git a/netbox/extras/api/serializers_/bookmarks.py b/netbox/extras/api/serializers_/bookmarks.py new file mode 100644 index 000000000..7a2d4d6aa --- /dev/null +++ b/netbox/extras/api/serializers_/bookmarks.py @@ -0,0 +1,35 @@ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from core.models import ObjectType +from extras.models import Bookmark +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer +from users.api.serializers_.users import UserSerializer +from utilities.api import get_serializer_for_model + +__all__ = ( + 'BookmarkSerializer', +) + + +class BookmarkSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') + object_type = ContentTypeField( + queryset=ObjectType.objects.with_feature('bookmarks'), + ) + object = serializers.SerializerMethodField(read_only=True) + user = UserSerializer(nested=True) + + class Meta: + model = Bookmark + 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): + serializer = get_serializer_for_model(instance.object) + context = {'request': self.context['request']} + return serializer(instance.object, nested=True, context=context).data diff --git a/netbox/extras/api/serializers_/change_logging.py b/netbox/extras/api/serializers_/change_logging.py new file mode 100644 index 000000000..32585637c --- /dev/null +++ b/netbox/extras/api/serializers_/change_logging.py @@ -0,0 +1,55 @@ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from extras.choices import * +from extras.models import ObjectChange +from netbox.api.exceptions import SerializerNotFound +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import BaseModelSerializer +from users.api.serializers_.users import UserSerializer +from utilities.api import get_serializer_for_model + +__all__ = ( + 'ObjectChangeSerializer', +) + + +class ObjectChangeSerializer(BaseModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail') + user = UserSerializer( + nested=True, + read_only=True + ) + action = ChoiceField( + choices=ObjectChangeActionChoices, + read_only=True + ) + changed_object_type = ContentTypeField( + read_only=True + ) + changed_object = serializers.SerializerMethodField( + read_only=True + ) + + class Meta: + model = ObjectChange + fields = [ + 'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', + 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data', + ] + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_changed_object(self, obj): + """ + Serialize a nested representation of the changed object. + """ + if obj.changed_object is None: + return None + + try: + serializer = get_serializer_for_model(obj.changed_object) + except SerializerNotFound: + return obj.object_repr + data = serializer(obj.changed_object, nested=True, context={'request': self.context['request']}).data + + return data diff --git a/netbox/extras/api/serializers_/configcontexts.py b/netbox/extras/api/serializers_/configcontexts.py new file mode 100644 index 000000000..e9688f254 --- /dev/null +++ b/netbox/extras/api/serializers_/configcontexts.py @@ -0,0 +1,131 @@ +from rest_framework import serializers + +from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer +from dcim.api.serializers_.devicetypes import DeviceTypeSerializer +from dcim.api.serializers_.platforms import PlatformSerializer +from dcim.api.serializers_.roles import DeviceRoleSerializer +from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup +from extras.models import ConfigContext, Tag +from netbox.api.fields import SerializedPKRelatedField +from netbox.api.serializers import ValidatedModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer +from tenancy.models import Tenant, TenantGroup +from virtualization.api.serializers_.clusters import ClusterSerializer, ClusterGroupSerializer, ClusterTypeSerializer +from virtualization.models import Cluster, ClusterGroup, ClusterType + +__all__ = ( + 'ConfigContextSerializer', +) + + +class ConfigContextSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail') + regions = SerializedPKRelatedField( + queryset=Region.objects.all(), + serializer=RegionSerializer, + nested=True, + required=False, + many=True + ) + site_groups = SerializedPKRelatedField( + queryset=SiteGroup.objects.all(), + serializer=SiteGroupSerializer, + nested=True, + required=False, + many=True + ) + sites = SerializedPKRelatedField( + queryset=Site.objects.all(), + serializer=SiteSerializer, + nested=True, + required=False, + many=True + ) + locations = SerializedPKRelatedField( + queryset=Location.objects.all(), + serializer=LocationSerializer, + nested=True, + required=False, + many=True + ) + device_types = SerializedPKRelatedField( + queryset=DeviceType.objects.all(), + serializer=DeviceTypeSerializer, + nested=True, + required=False, + many=True + ) + roles = SerializedPKRelatedField( + queryset=DeviceRole.objects.all(), + serializer=DeviceRoleSerializer, + nested=True, + required=False, + many=True + ) + platforms = SerializedPKRelatedField( + queryset=Platform.objects.all(), + serializer=PlatformSerializer, + nested=True, + required=False, + many=True + ) + cluster_types = SerializedPKRelatedField( + queryset=ClusterType.objects.all(), + serializer=ClusterTypeSerializer, + nested=True, + required=False, + many=True + ) + cluster_groups = SerializedPKRelatedField( + queryset=ClusterGroup.objects.all(), + serializer=ClusterGroupSerializer, + nested=True, + required=False, + many=True + ) + clusters = SerializedPKRelatedField( + queryset=Cluster.objects.all(), + serializer=ClusterSerializer, + nested=True, + required=False, + many=True + ) + tenant_groups = SerializedPKRelatedField( + queryset=TenantGroup.objects.all(), + serializer=TenantGroupSerializer, + nested=True, + required=False, + many=True + ) + tenants = SerializedPKRelatedField( + queryset=Tenant.objects.all(), + serializer=TenantSerializer, + nested=True, + required=False, + many=True + ) + tags = serializers.SlugRelatedField( + queryset=Tag.objects.all(), + slug_field='slug', + required=False, + many=True + ) + data_source = DataSourceSerializer( + nested=True, + required=False + ) + data_file = DataFileSerializer( + nested=True, + read_only=True + ) + + class Meta: + model = ConfigContext + fields = [ + 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', + 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', + 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data', + 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py new file mode 100644 index 000000000..935214478 --- /dev/null +++ b/netbox/extras/api/serializers_/configtemplates.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer +from extras.models import ConfigTemplate +from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers.features import TaggableModelSerializer + +__all__ = ( + 'ConfigTemplateSerializer', +) + + +class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail') + data_source = DataSourceSerializer( + nested=True, + required=False + ) + data_file = DataFileSerializer( + nested=True, + required=False + ) + + class Meta: + model = ConfigTemplate + fields = [ + 'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source', + 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/customfields.py b/netbox/extras/api/serializers_/customfields.py new file mode 100644 index 000000000..79bb39557 --- /dev/null +++ b/netbox/extras/api/serializers_/customfields.py @@ -0,0 +1,91 @@ +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 + +from core.models import ObjectType +from extras.choices import * +from extras.models import CustomField, CustomFieldChoiceSet +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'CustomFieldChoiceSetSerializer', + 'CustomFieldSerializer', +) + + +class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail') + base_choices = ChoiceField( + choices=CustomFieldChoiceSetBaseChoices, + required=False + ) + extra_choices = serializers.ListField( + child=serializers.ListField( + min_length=2, + max_length=2 + ) + ) + + class Meta: + model = CustomFieldChoiceSet + fields = [ + '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') + + +class CustomFieldSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.with_feature('custom_fields'), + many=True + ) + type = ChoiceField(choices=CustomFieldTypeChoices) + related_object_type = ContentTypeField( + queryset=ObjectType.objects.all(), + required=False, + allow_null=True + ) + filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) + data_type = serializers.SerializerMethodField() + choice_set = CustomFieldChoiceSetSerializer( + nested=True, + required=False, + allow_null=True + ) + ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False) + ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False) + + class Meta: + model = CustomField + fields = [ + 'id', 'url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'name', 'label', + 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', + 'is_cloneable', '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.')) + + return value + + @extend_schema_field(OpenApiTypes.STR) + def get_data_type(self, obj): + types = CustomFieldTypeChoices + if obj.type == types.TYPE_INTEGER: + return 'integer' + if obj.type == types.TYPE_DECIMAL: + return 'decimal' + if obj.type == types.TYPE_BOOLEAN: + return 'boolean' + if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): + return 'object' + if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT): + return 'array' + return 'string' diff --git a/netbox/extras/api/serializers_/customlinks.py b/netbox/extras/api/serializers_/customlinks.py new file mode 100644 index 000000000..8635ea2a0 --- /dev/null +++ b/netbox/extras/api/serializers_/customlinks.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from core.models import ObjectType +from extras.models import CustomLink +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'CustomLinkSerializer', +) + + +class CustomLinkSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.with_feature('custom_links'), + many=True + ) + + class Meta: + model = CustomLink + fields = [ + 'id', 'url', 'display', 'object_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', + 'button_class', 'new_window', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name') diff --git a/netbox/extras/api/serializers_/dashboard.py b/netbox/extras/api/serializers_/dashboard.py new file mode 100644 index 000000000..74d5daecf --- /dev/null +++ b/netbox/extras/api/serializers_/dashboard.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from extras.models import Dashboard + +__all__ = ( + 'DashboardSerializer', +) + + +class DashboardSerializer(serializers.ModelSerializer): + class Meta: + model = Dashboard + fields = ('layout', 'config') diff --git a/netbox/extras/api/serializers_/events.py b/netbox/extras/api/serializers_/events.py new file mode 100644 index 000000000..4285b12e6 --- /dev/null +++ b/netbox/extras/api/serializers_/events.py @@ -0,0 +1,71 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from core.models import ObjectType +from extras.choices import * +from extras.models import EventRule, Webhook +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import NetBoxModelSerializer +from utilities.api import get_serializer_for_model +from .scripts import ScriptSerializer + +__all__ = ( + 'EventRuleSerializer', + 'WebhookSerializer', +) + + +# +# Event Rules +# + +class EventRuleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.with_feature('event_rules'), + many=True + ) + action_type = ChoiceField(choices=EventRuleActionChoices) + action_object_type = ContentTypeField( + queryset=ObjectType.objects.with_feature('event_rules'), + ) + action_object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = EventRule + fields = [ + 'id', 'url', 'display', 'object_types', 'name', 'type_create', 'type_update', 'type_delete', + '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 = instance.action_object + instance = script.python_class() if script.python_class else None + return ScriptSerializer(instance, nested=True, context=context).data + else: + serializer = get_serializer_for_model(instance.action_object_type.model_class()) + return serializer(instance.action_object, nested=True, context=context).data + + +# +# Webhooks +# + +class WebhookSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') + + class Meta: + model = Webhook + fields = [ + 'id', 'url', 'display', 'name', 'description', 'payload_url', 'http_method', 'http_content_type', + 'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields', + 'tags', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py new file mode 100644 index 000000000..43cc061a7 --- /dev/null +++ b/netbox/extras/api/serializers_/exporttemplates.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer +from core.models import ObjectType +from extras.models import ExportTemplate +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'ExportTemplateSerializer', +) + + +class ExportTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.with_feature('export_templates'), + many=True + ) + data_source = DataSourceSerializer( + nested=True, + required=False + ) + data_file = DataFileSerializer( + nested=True, + read_only=True + ) + + class Meta: + model = ExportTemplate + fields = [ + 'id', 'url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type', + 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/journaling.py b/netbox/extras/api/serializers_/journaling.py new file mode 100644 index 000000000..46ab0477b --- /dev/null +++ b/netbox/extras/api/serializers_/journaling.py @@ -0,0 +1,63 @@ +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from core.models import ObjectType +from extras.choices import * +from extras.models import JournalEntry +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import NetBoxModelSerializer +from utilities.api import get_serializer_for_model + +__all__ = ( + 'JournalEntrySerializer', +) + + +class JournalEntrySerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') + assigned_object_type = ContentTypeField( + queryset=ObjectType.objects.all() + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + created_by = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=get_user_model().objects.all(), + required=False, + default=serializers.CurrentUserDefault() + ) + kind = ChoiceField( + choices=JournalEntryKindChoices, + required=False + ) + + class Meta: + model = JournalEntry + fields = [ + '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): + + # Validate that the parent object exists + if 'assigned_object_type' in data and 'assigned_object_id' in data: + try: + data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}" + ) + + # Enforce model validation + super().validate(data) + + return data + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_assigned_object(self, instance): + serializer = get_serializer_for_model(instance.assigned_object_type.model_class()) + context = {'request': self.context['request']} + return serializer(instance.assigned_object, nested=True, context=context).data diff --git a/netbox/extras/api/serializers_/objecttypes.py b/netbox/extras/api/serializers_/objecttypes.py new file mode 100644 index 000000000..8e4806652 --- /dev/null +++ b/netbox/extras/api/serializers_/objecttypes.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from core.models import ObjectType +from netbox.api.serializers import BaseModelSerializer + +__all__ = ( + 'ObjectTypeSerializer', +) + + +class ObjectTypeSerializer(BaseModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail') + + class Meta: + model = ObjectType + fields = ['id', 'url', 'display', 'app_label', 'model'] diff --git a/netbox/extras/api/serializers_/savedfilters.py b/netbox/extras/api/serializers_/savedfilters.py new file mode 100644 index 000000000..9e26f0c30 --- /dev/null +++ b/netbox/extras/api/serializers_/savedfilters.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from core.models import ObjectType +from extras.models import SavedFilter +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'SavedFilterSerializer', +) + + +class SavedFilterSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.all(), + many=True + ) + + class Meta: + model = SavedFilter + fields = [ + 'id', 'url', 'display', 'object_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled', + 'shared', 'parameters', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description') diff --git a/netbox/extras/api/serializers_/scripts.py b/netbox/extras/api/serializers_/scripts.py new file mode 100644 index 000000000..b2a8ef29d --- /dev/null +++ b/netbox/extras/api/serializers_/scripts.py @@ -0,0 +1,77 @@ +from django.utils.translation import gettext as _ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from core.api.serializers_.jobs import JobSerializer +from extras.models import Script +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'ScriptDetailSerializer', + 'ScriptInputSerializer', + 'ScriptSerializer', +) + + +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 = JobSerializer(nested=True, 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, 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 = 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): + data = serializers.JSONField() + commit = serializers.BooleanField() + 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['script'].scheduling_enabled: + 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.")) + return value diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py new file mode 100644 index 000000000..9d91ba5e1 --- /dev/null +++ b/netbox/extras/api/serializers_/tags.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from core.models import ObjectType +from extras.models import Tag +from netbox.api.fields import ContentTypeField, RelatedObjectCountField +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'TagSerializer', +) + + +class TagSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.with_feature('tags'), + many=True, + required=False + ) + + # Related object counts + tagged_items = RelatedObjectCountField('extras_taggeditem_items') + + class Meta: + model = Tag + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description') diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 68b4488bc..301cc1b0a 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -22,7 +22,7 @@ router.register('config-contexts', views.ConfigContextViewSet) router.register('config-templates', views.ConfigTemplateViewSet) router.register('scripts', views.ScriptViewSet, basename='script') router.register('object-changes', views.ObjectChangeViewSet) -router.register('content-types', views.ContentTypeViewSet) +router.register('object-types', views.ObjectTypeViewSet) app_name = 'extras-api' urlpatterns = [ diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 72450f9c9..3439f6f3f 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,4 +1,3 @@ -from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from django_rq.queues import get_connection from rest_framework import status @@ -11,7 +10,7 @@ from rest_framework.routers import APIRootView from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rq import Worker -from core.models import Job +from core.models import Job, ObjectType from extras import filtersets from extras.models import * from extras.scripts import run_script @@ -275,17 +274,17 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet): # -# ContentTypes +# Object types # -class ContentTypeViewSet(ReadOnlyModelViewSet): +class ObjectTypeViewSet(ReadOnlyModelViewSet): """ - Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects. + Read-only list of ObjectTypes. """ permission_classes = [IsAuthenticatedOrLoginNotRequired] - queryset = ContentType.objects.order_by('app_label', 'model') - serializer_class = serializers.ContentTypeSerializer - filterset_class = filtersets.ContentTypeFilterSet + queryset = ObjectType.objects.order_by('app_label', 'model') + serializer_class = serializers.ObjectTypeSerializer + filterset_class = filtersets.ObjectTypeFilterSet # diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 1bdc4bc1d..69bef0d8f 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -12,7 +12,7 @@ from django.template.loader import render_to_string from django.urls import NoReverseMatch, resolve, reverse from django.utils.translation import gettext as _ -from core.models import ContentType +from core.models import ObjectType from extras.choices import BookmarkOrderingChoices from utilities.choices import ButtonColorChoices from utilities.permissions import get_permission_for_model @@ -34,14 +34,14 @@ __all__ = ( def get_object_type_choices(): return [ (content_type_identifier(ct), content_type_name(ct)) - for ct in ContentType.objects.public().order_by('app_label', 'model') + for ct in ObjectType.objects.public().order_by('app_label', 'model') ] def get_bookmarks_object_type_choices(): return [ (content_type_identifier(ct), content_type_name(ct)) - for ct in ContentType.objects.with_feature('bookmarks').order_by('app_label', 'model') + for ct in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model') ] @@ -52,7 +52,7 @@ def get_models_from_content_types(content_types): models = [] for content_type_id in content_types: app_label, model_name = content_type_id.split('.') - content_type = ContentType.objects.get_by_natural_key(app_label, model_name) + content_type = ObjectType.objects.get_by_natural_key(app_label, model_name) models.append(content_type.model_class()) return models @@ -238,7 +238,7 @@ class ObjectListWidget(DashboardWidget): def render(self, request): app_label, model_name = self.config['model'].split('.') - model = ContentType.objects.get_by_natural_key(app_label, model_name).model_class() + model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class() viewname = get_viewname(model, action='list') # Evaluate user's permission. Note that this controls only whether the HTMX element is @@ -371,7 +371,7 @@ class BookmarksWidget(DashboardWidget): bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by']) if object_types := self.config.get('object_types'): models = get_models_from_content_types(object_types) - conent_types = ContentType.objects.get_for_models(*models).values() + conent_types = ObjectType.objects.get_for_models(*models).values() bookmarks = bookmarks.filter(object_type__in=conent_types) if max_items := self.config.get('max_items'): bookmarks = bookmarks[:max_items] diff --git a/netbox/extras/events.py b/netbox/extras/events.py index e7706ea9f..0ee4cffa8 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -155,7 +155,7 @@ def process_event_queue(events): if content_type not in events_cache[action_flag]: events_cache[action_flag][content_type] = EventRule.objects.filter( **{action_flag: True}, - content_types=content_type, + object_types=content_type, enabled=True ) event_rules = events_cache[action_flag][content_type] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 734f7db50..4674335c9 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext as _ -from core.models import DataSource +from core.models import DataSource, ObjectType from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup @@ -18,7 +18,6 @@ __all__ = ( 'BookmarkFilterSet', 'ConfigContextFilterSet', 'ConfigTemplateFilterSet', - 'ContentTypeFilterSet', 'CustomFieldChoiceSetFilterSet', 'CustomFieldFilterSet', 'CustomLinkFilterSet', @@ -28,6 +27,7 @@ __all__ = ( 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', + 'ObjectTypeFilterSet', 'SavedFilterFilterSet', 'ScriptFilterSet', 'TagFilterSet', @@ -40,12 +40,14 @@ class ScriptFilterSet(BaseFilterSet): method='search', label=_('Search'), ) + module_id = django_filters.ModelMultipleChoiceFilter( + queryset=ScriptModule.objects.all(), + label=_('Script module (ID)'), + ) class Meta: model = Script - fields = [ - 'id', 'name', - ] + fields = ('id', 'name', 'is_executable') def search(self, queryset, name, value): if not value.strip(): @@ -69,10 +71,10 @@ class WebhookFilterSet(NetBoxModelFilterSet): class Meta: model = Webhook - fields = [ + fields = ( 'id', 'name', 'payload_url', 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', 'description', - ] + ) def search(self, queryset, name, value): if not value.strip(): @@ -89,10 +91,13 @@ class EventRuleFilterSet(NetBoxModelFilterSet): method='search', label=_('Search'), ) - content_type_id = MultiValueNumberFilter( - field_name='content_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' + ) + object_type = ContentTypeFilter( + field_name='object_types' ) - content_types = ContentTypeFilter() action_type = django_filters.MultipleChoiceFilter( choices=EventRuleActionChoices ) @@ -101,10 +106,10 @@ class EventRuleFilterSet(NetBoxModelFilterSet): class Meta: model = EventRule - fields = [ + fields = ( 'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'enabled', 'action_type', 'description', - ] + ) def search(self, queryset, name, value): if not value.strip(): @@ -116,7 +121,7 @@ class EventRuleFilterSet(NetBoxModelFilterSet): ) -class CustomFieldFilterSet(BaseFilterSet): +class CustomFieldFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -124,10 +129,18 @@ class CustomFieldFilterSet(BaseFilterSet): type = django_filters.MultipleChoiceFilter( choices=CustomFieldTypeChoices ) - content_type_id = MultiValueNumberFilter( - field_name='content_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' ) - content_types = ContentTypeFilter() + object_type = ContentTypeFilter( + field_name='object_types' + ) + related_object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='related_object_type' + ) + related_object_type = ContentTypeFilter() choice_set_id = django_filters.ModelMultipleChoiceFilter( queryset=CustomFieldChoiceSet.objects.all() ) @@ -139,10 +152,11 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField - fields = [ - 'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible', - 'ui_editable', 'weight', 'is_cloneable', 'description', - ] + fields = ( + 'id', 'name', 'label', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible', + 'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum', + 'validation_regex', + ) def search(self, queryset, name, value): if not value.strip(): @@ -155,7 +169,7 @@ class CustomFieldFilterSet(BaseFilterSet): ) -class CustomFieldChoiceSetFilterSet(BaseFilterSet): +class CustomFieldChoiceSetFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -166,9 +180,9 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet): class Meta: model = CustomFieldChoiceSet - fields = [ + fields = ( 'id', 'name', 'description', 'base_choices', 'order_alphabetically', - ] + ) def search(self, queryset, name, value): if not value.strip(): @@ -183,21 +197,24 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet): return queryset.filter(extra_choices__overlap=value) -class CustomLinkFilterSet(BaseFilterSet): +class CustomLinkFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), ) - content_type_id = MultiValueNumberFilter( - field_name='content_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' + ) + object_type = ContentTypeFilter( + field_name='object_types' ) - content_types = ContentTypeFilter() class Meta: model = CustomLink - fields = [ - 'id', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', - ] + fields = ( + 'id', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', 'button_class', + ) def search(self, queryset, name, value): if not value.strip(): @@ -210,15 +227,18 @@ class CustomLinkFilterSet(BaseFilterSet): ) -class ExportTemplateFilterSet(BaseFilterSet): +class ExportTemplateFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), ) - content_type_id = MultiValueNumberFilter( - field_name='content_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' + ) + object_type = ContentTypeFilter( + field_name='object_types' ) - content_types = ContentTypeFilter() data_source_id = django_filters.ModelMultipleChoiceFilter( queryset=DataSource.objects.all(), label=_('Data source (ID)'), @@ -230,7 +250,10 @@ class ExportTemplateFilterSet(BaseFilterSet): class Meta: model = ExportTemplate - fields = ['id', 'content_types', 'name', 'description', 'data_synced'] + fields = ( + 'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled', + 'data_synced', + ) def search(self, queryset, name, value): if not value.strip(): @@ -241,15 +264,18 @@ class ExportTemplateFilterSet(BaseFilterSet): ) -class SavedFilterFilterSet(BaseFilterSet): +class SavedFilterFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), ) - content_type_id = MultiValueNumberFilter( - field_name='content_types__id' + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ObjectType.objects.all(), + field_name='object_types' + ) + object_type = ContentTypeFilter( + field_name='object_types' ) - content_types = ContentTypeFilter() user_id = django_filters.ModelMultipleChoiceFilter( queryset=get_user_model().objects.all(), label=_('User (ID)'), @@ -266,7 +292,7 @@ class SavedFilterFilterSet(BaseFilterSet): class Meta: model = SavedFilter - fields = ['id', 'content_types', 'name', 'slug', 'description', 'enabled', 'shared', 'weight'] + fields = ('id', 'name', 'slug', 'description', 'enabled', 'shared', 'weight') def search(self, queryset, name, value): if not value.strip(): @@ -307,20 +333,19 @@ class BookmarkFilterSet(BaseFilterSet): class Meta: model = Bookmark - fields = ['id', 'object_id'] + fields = ('id', 'object_id') -class ImageAttachmentFilterSet(BaseFilterSet): +class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), ) - created = django_filters.DateTimeFilter() - content_type = ContentTypeFilter() + object_type = ContentTypeFilter() class Meta: model = ImageAttachment - fields = ['id', 'content_type_id', 'object_id', 'name'] + fields = ('id', 'object_type_id', 'object_id', 'name', 'image_width', 'image_height') def search(self, queryset, name, value): if not value.strip(): @@ -350,7 +375,7 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): class Meta: model = JournalEntry - fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind'] + fields = ('id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind') def search(self, queryset, name, value): if not value.strip(): @@ -375,7 +400,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet): class Meta: model = Tag - fields = ['id', 'name', 'slug', 'color', 'description', 'object_types'] + fields = ('id', 'name', 'slug', 'color', 'description', 'object_types') def search(self, queryset, name, value): if not value.strip(): @@ -472,12 +497,12 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): queryset=DeviceType.objects.all(), label=_('Device type'), ) - role_id = django_filters.ModelMultipleChoiceFilter( + device_role_id = django_filters.ModelMultipleChoiceFilter( field_name='roles', queryset=DeviceRole.objects.all(), label=_('Role'), ) - role = django_filters.ModelMultipleChoiceFilter( + device_role = django_filters.ModelMultipleChoiceFilter( field_name='roles__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', @@ -563,9 +588,13 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): label=_('Data file (ID)'), ) + # TODO: Remove in v4.1 + role = device_role + role_id = device_role_id + class Meta: model = ConfigContext - fields = ['id', 'name', 'is_active', 'data_synced', 'description'] + fields = ('id', 'name', 'is_active', 'description', 'weight', 'auto_sync_enabled', 'data_synced') def search(self, queryset, name, value): if not value.strip(): @@ -577,7 +606,7 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): ) -class ConfigTemplateFilterSet(BaseFilterSet): +class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), @@ -594,7 +623,7 @@ class ConfigTemplateFilterSet(BaseFilterSet): class Meta: model = ConfigTemplate - fields = ['id', 'name', 'description', 'data_synced'] + fields = ('id', 'name', 'description', 'auto_sync_enabled', 'data_synced') def search(self, queryset, name, value): if not value.strip(): @@ -642,10 +671,10 @@ class ObjectChangeFilterSet(BaseFilterSet): class Meta: model = ObjectChange - fields = [ + fields = ( 'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id', - 'object_repr', - ] + 'related_object_type', 'related_object_id', 'object_repr', + ) def search(self, queryset, name, value): if not value.strip(): @@ -660,15 +689,15 @@ class ObjectChangeFilterSet(BaseFilterSet): # ContentTypes # -class ContentTypeFilterSet(django_filters.FilterSet): +class ObjectTypeFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), ) class Meta: - model = ContentType - fields = ['id', 'app_label', 'model'] + model = ObjectType + fields = ('id', 'app_label', 'model') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 440600af5..55f71dbd2 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -6,7 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from core.models import ContentType +from core.models import ObjectType from extras.choices import * from extras.models import * from netbox.forms import NetBoxModelImportForm @@ -30,9 +30,9 @@ __all__ = ( class CustomFieldImportForm(CSVModelForm): - content_types = CSVMultipleContentTypeField( - label=_('Content types'), - queryset=ContentType.objects.with_feature('custom_fields'), + object_types = CSVMultipleContentTypeField( + label=_('Object types'), + queryset=ObjectType.objects.with_feature('custom_fields'), help_text=_("One or more assigned object types") ) type = CSVChoiceField( @@ -40,9 +40,9 @@ class CustomFieldImportForm(CSVModelForm): choices=CustomFieldTypeChoices, help_text=_('Field data type (e.g. text, integer, etc.)') ) - object_type = CSVContentTypeField( + related_object_type = CSVContentTypeField( label=_('Object type'), - queryset=ContentType.objects.public(), + queryset=ObjectType.objects.public(), required=False, help_text=_("Object type (for object or multi-object fields)") ) @@ -69,7 +69,7 @@ class CustomFieldImportForm(CSVModelForm): class Meta: model = CustomField fields = ( - 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', + 'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'description', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable', ) @@ -111,31 +111,31 @@ class CustomFieldChoiceSetImportForm(CSVModelForm): class CustomLinkImportForm(CSVModelForm): - content_types = CSVMultipleContentTypeField( - label=_('Content types'), - queryset=ContentType.objects.with_feature('custom_links'), + object_types = CSVMultipleContentTypeField( + label=_('Object types'), + queryset=ObjectType.objects.with_feature('custom_links'), help_text=_("One or more assigned object types") ) class Meta: model = CustomLink fields = ( - 'name', 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', + 'name', 'object_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', ) class ExportTemplateImportForm(CSVModelForm): - content_types = CSVMultipleContentTypeField( - label=_('Content types'), - queryset=ContentType.objects.with_feature('export_templates'), + object_types = CSVMultipleContentTypeField( + label=_('Object types'), + queryset=ObjectType.objects.with_feature('export_templates'), help_text=_("One or more assigned object types") ) class Meta: model = ExportTemplate fields = ( - 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', + 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', ) @@ -149,16 +149,16 @@ class ConfigTemplateImportForm(CSVModelForm): class SavedFilterImportForm(CSVModelForm): - content_types = CSVMultipleContentTypeField( - label=_('Content types'), - queryset=ContentType.objects.all(), + object_types = CSVMultipleContentTypeField( + label=_('Object types'), + queryset=ObjectType.objects.all(), help_text=_("One or more assigned object types") ) class Meta: model = SavedFilter fields = ( - 'name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters', + 'name', 'slug', 'object_types', 'description', 'weight', 'enabled', 'shared', 'parameters', ) @@ -173,9 +173,9 @@ class WebhookImportForm(NetBoxModelImportForm): class EventRuleImportForm(NetBoxModelImportForm): - content_types = CSVMultipleContentTypeField( - label=_('Content types'), - queryset=ContentType.objects.with_feature('event_rules'), + object_types = CSVMultipleContentTypeField( + label=_('Object types'), + queryset=ObjectType.objects.with_feature('event_rules'), help_text=_("One or more assigned object types") ) action_object = forms.CharField( @@ -187,7 +187,7 @@ class EventRuleImportForm(NetBoxModelImportForm): class Meta: model = EventRule fields = ( - 'name', 'description', 'enabled', 'conditions', 'content_types', 'type_create', 'type_update', + 'name', 'description', 'enabled', 'conditions', 'object_types', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'action_type', 'action_object', 'comments', 'tags' ) @@ -213,7 +213,7 @@ class EventRuleImportForm(NetBoxModelImportForm): except ObjectDoesNotExist: raise forms.ValidationError(_("Script {name} not found").format(name=action_object)) self.instance.action_object = script - self.instance.action_object_type = ContentType.objects.get_for_model(script, for_concrete_model=False) + self.instance.action_object_type = ObjectType.objects.get_for_model(script, for_concrete_model=False) class TagImportForm(CSVModelForm): @@ -229,7 +229,7 @@ class TagImportForm(CSVModelForm): class JournalEntryImportForm(NetBoxModelImportForm): assigned_object_type = CSVContentTypeField( - queryset=ContentType.objects.all(), + queryset=ObjectType.objects.all(), label=_('Assigned object type'), ) kind = CSVChoiceField( diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 3a6421901..73751872f 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -2,7 +2,7 @@ from django import forms from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ -from core.models import ContentType, DataFile, DataSource +from core.models import ObjectType, DataFile, DataSource from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * @@ -38,14 +38,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), (_('Attributes'), ( - 'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable', - 'is_cloneable', + 'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', + 'ui_editable', 'is_cloneable', )), ) - content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.with_feature('custom_fields'), + related_object_type_id = ContentTypeMultipleChoiceField( + queryset=ObjectType.objects.with_feature('custom_fields'), required=False, - label=_('Object type') + label=_('Related object type') ) type = forms.MultipleChoiceField( choices=CustomFieldTypeChoices, @@ -108,11 +108,11 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm): class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), - (_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')), + (_('Attributes'), ('object_type', 'enabled', 'new_window', 'weight')), ) - content_types = ContentTypeMultipleChoiceField( - label=_('Content types'), - queryset=ContentType.objects.with_feature('custom_links'), + object_type = ContentTypeMultipleChoiceField( + label=_('Object types'), + queryset=ObjectType.objects.with_feature('custom_links'), required=False ) enabled = forms.NullBooleanField( @@ -139,7 +139,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), (_('Data'), ('data_source_id', 'data_file_id')), - (_('Attributes'), ('content_type_id', 'mime_type', 'file_extension', 'as_attachment')), + (_('Attributes'), ('object_type_id', 'mime_type', 'file_extension', 'as_attachment')), ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -154,8 +154,8 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): 'source_id': '$data_source_id' } ) - content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.with_feature('export_templates'), + object_type_id = ContentTypeMultipleChoiceField( + queryset=ObjectType.objects.with_feature('export_templates'), required=False, label=_('Content types') ) @@ -179,11 +179,11 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), - (_('Attributes'), ('content_type_id', 'name',)), + (_('Attributes'), ('object_type_id', 'name',)), ) - content_type_id = ContentTypeChoiceField( - label=_('Content type'), - queryset=ContentType.objects.with_feature('image_attachments'), + object_type_id = ContentTypeChoiceField( + label=_('Object type'), + queryset=ObjectType.objects.with_feature('image_attachments'), required=False ) name = forms.CharField( @@ -195,11 +195,11 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), - (_('Attributes'), ('content_types', 'enabled', 'shared', 'weight')), + (_('Attributes'), ('object_type', 'enabled', 'shared', 'weight')), ) - content_types = ContentTypeMultipleChoiceField( - label=_('Content types'), - queryset=ContentType.objects.public(), + object_type = ContentTypeMultipleChoiceField( + label=_('Object types'), + queryset=ObjectType.objects.public(), required=False ) enabled = forms.NullBooleanField( @@ -250,11 +250,11 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('content_type_id', 'action_type', 'enabled')), + (_('Attributes'), ('object_type_id', 'action_type', 'enabled')), (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), ) - content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.with_feature('event_rules'), + object_type_id = ContentTypeMultipleChoiceField( + queryset=ObjectType.objects.with_feature('event_rules'), required=False, label=_('Object type') ) @@ -310,12 +310,12 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm): class TagFilterForm(SavedFiltersMixin, FilterForm): model = Tag content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.with_feature('tags'), + queryset=ObjectType.objects.with_feature('tags'), required=False, label=_('Tagged object type') ) for_object_type_id = ContentTypeChoiceField( - queryset=ContentType.objects.with_feature('tags'), + queryset=ObjectType.objects.with_feature('tags'), required=False, label=_('Allowed object type') ) @@ -464,7 +464,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): label=_('User') ) assigned_object_type_id = DynamicModelMultipleChoiceField( - queryset=ContentType.objects.all(), + queryset=ObjectType.objects.all(), required=False, label=_('Object Type'), widget=APISelectMultiple( @@ -507,7 +507,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): label=_('User') ) changed_object_type_id = DynamicModelMultipleChoiceField( - queryset=ContentType.objects.all(), + queryset=ObjectType.objects.all(), required=False, label=_('Object Type'), widget=APISelectMultiple( diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 5c3671b3c..09d2d9535 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -2,12 +2,11 @@ import json import re from django import forms -from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from core.forms.mixins import SyncedDataMixin -from core.models import ContentType +from core.models import ObjectType from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * @@ -39,13 +38,13 @@ __all__ = ( class CustomFieldForm(forms.ModelForm): - content_types = ContentTypeMultipleChoiceField( - label=_('Content types'), - queryset=ContentType.objects.with_feature('custom_fields') + object_types = ContentTypeMultipleChoiceField( + label=_('Object types'), + queryset=ObjectType.objects.with_feature('custom_fields') ) - object_type = ContentTypeChoiceField( - label=_('Object type'), - queryset=ContentType.objects.public(), + related_object_type = ContentTypeChoiceField( + label=_('Related object type'), + queryset=ObjectType.objects.public(), required=False, help_text=_("Type of the related object (for object/multi-object fields only)") ) @@ -56,7 +55,7 @@ class CustomFieldForm(forms.ModelForm): fieldsets = ( (_('Custom Field'), ( - 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description', + 'object_types', 'name', 'label', 'group_name', 'type', 'related_object_type', 'required', 'description', )), (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')), (_('Values'), ('default', 'choice_set')), @@ -123,13 +122,13 @@ class CustomFieldChoiceSetForm(forms.ModelForm): class CustomLinkForm(forms.ModelForm): - content_types = ContentTypeMultipleChoiceField( - label=_('Content types'), - queryset=ContentType.objects.with_feature('custom_links') + object_types = ContentTypeMultipleChoiceField( + label=_('Object types'), + queryset=ObjectType.objects.with_feature('custom_links') ) fieldsets = ( - (_('Custom Link'), ('name', 'content_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), + (_('Custom Link'), ('name', 'object_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), (_('Templates'), ('link_text', 'link_url')), ) @@ -152,9 +151,9 @@ class CustomLinkForm(forms.ModelForm): class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): - content_types = ContentTypeMultipleChoiceField( - label=_('Content types'), - queryset=ContentType.objects.with_feature('export_templates') + object_types = ContentTypeMultipleChoiceField( + label=_('Object types'), + queryset=ObjectType.objects.with_feature('export_templates') ) template_code = forms.CharField( label=_('Template code'), @@ -163,7 +162,7 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): ) fieldsets = ( - (_('Export Template'), ('name', 'content_types', 'description', 'template_code')), + (_('Export Template'), ('name', 'object_types', 'description', 'template_code')), (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')), (_('Rendering'), ('mime_type', 'file_extension', 'as_attachment')), ) @@ -193,14 +192,14 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): class SavedFilterForm(forms.ModelForm): slug = SlugField() - content_types = ContentTypeMultipleChoiceField( - label=_('Content types'), - queryset=ContentType.objects.all() + object_types = ContentTypeMultipleChoiceField( + label=_('Object types'), + queryset=ObjectType.objects.all() ) parameters = JSONField() fieldsets = ( - (_('Saved Filter'), ('name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared')), + (_('Saved Filter'), ('name', 'slug', 'object_types', 'description', 'weight', 'enabled', 'shared')), (_('Parameters'), ('parameters',)), ) @@ -221,7 +220,7 @@ class SavedFilterForm(forms.ModelForm): class BookmarkForm(forms.ModelForm): object_type = ContentTypeChoiceField( label=_('Object type'), - queryset=ContentType.objects.with_feature('bookmarks') + queryset=ObjectType.objects.with_feature('bookmarks') ) class Meta: @@ -249,9 +248,9 @@ class WebhookForm(NetBoxModelForm): class EventRuleForm(NetBoxModelForm): - content_types = ContentTypeMultipleChoiceField( - label=_('Content types'), - queryset=ContentType.objects.with_feature('event_rules'), + object_types = ContentTypeMultipleChoiceField( + label=_('Object types'), + queryset=ObjectType.objects.with_feature('event_rules'), ) action_choice = forms.ChoiceField( label=_('Action choice'), @@ -267,7 +266,7 @@ class EventRuleForm(NetBoxModelForm): ) fieldsets = ( - (_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')), + (_('Event Rule'), ('name', 'description', 'object_types', 'enabled', 'tags')), (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), (_('Conditions'), ('conditions',)), (_('Action'), ( @@ -278,7 +277,7 @@ class EventRuleForm(NetBoxModelForm): class Meta: model = EventRule fields = ( - 'content_types', 'name', 'description', 'type_create', 'type_update', 'type_delete', 'type_job_start', + 'object_types', 'name', 'description', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', 'action_object_id', 'action_data', 'comments', 'tags' ) @@ -339,11 +338,11 @@ class EventRuleForm(NetBoxModelForm): action_choice = self.cleaned_data.get('action_choice') # Webhook if self.cleaned_data.get('action_type') == EventRuleActionChoices.WEBHOOK: - self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(action_choice) + self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice) self.cleaned_data['action_object_id'] = action_choice.id # Script elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT: - self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model( + self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model( Script, for_concrete_model=False ) @@ -356,7 +355,7 @@ class TagForm(forms.ModelForm): slug = SlugField() object_types = ContentTypeMultipleChoiceField( label=_('Object types'), - queryset=ContentType.objects.with_feature('tags'), + queryset=ObjectType.objects.with_feature('tags'), required=False ) diff --git a/netbox/extras/graphql/mixins.py b/netbox/extras/graphql/mixins.py index 7045575fb..68fba5ee6 100644 --- a/netbox/extras/graphql/mixins.py +++ b/netbox/extras/graphql/mixins.py @@ -7,6 +7,7 @@ from extras.models import ObjectChange __all__ = ( 'ChangelogMixin', 'ConfigContextMixin', + 'ContactsMixin', 'CustomFieldsMixin', 'ImageAttachmentsMixin', 'JournalEntriesMixin', diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 4981ddd72..d99e54976 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -39,7 +39,7 @@ class CustomFieldType(ObjectType): class Meta: model = models.CustomField - exclude = ('content_types', ) + fields = '__all__' filterset_class = filtersets.CustomFieldFilterSet @@ -55,15 +55,23 @@ class CustomLinkType(ObjectType): class Meta: model = models.CustomLink - exclude = ('content_types', ) + fields = '__all__' filterset_class = filtersets.CustomLinkFilterSet +class EventRuleType(OrganizationalObjectType): + + class Meta: + model = models.EventRule + fields = '__all__' + filterset_class = filtersets.EventRuleFilterSet + + class ExportTemplateType(ObjectType): class Meta: model = models.ExportTemplate - exclude = ('content_types', ) + fields = '__all__' filterset_class = filtersets.ExportTemplateFilterSet @@ -95,7 +103,7 @@ class SavedFilterType(ObjectType): class Meta: model = models.SavedFilter - exclude = ('content_types', ) + fields = '__all__' filterset_class = filtersets.SavedFilterFilterSet @@ -112,11 +120,3 @@ class WebhookType(OrganizationalObjectType): class Meta: model = models.Webhook filterset_class = filtersets.WebhookFilterSet - - -class EventRuleType(OrganizationalObjectType): - - class Meta: - model = models.EventRule - exclude = ('content_types', ) - filterset_class = filtersets.EventRuleFilterSet diff --git a/netbox/extras/migrations/0108_convert_reports_to_scripts.py b/netbox/extras/migrations/0108_convert_reports_to_scripts.py index 072353550..b547c41c3 100644 --- a/netbox/extras/migrations/0108_convert_reports_to_scripts.py +++ b/netbox/extras/migrations/0108_convert_reports_to_scripts.py @@ -25,7 +25,4 @@ class Migration(migrations.Migration): migrations.DeleteModel( name='Report', ), - migrations.DeleteModel( - name='ReportModule', - ), ] diff --git a/netbox/extras/migrations/0109_script_model.py b/netbox/extras/migrations/0109_script_model.py index 89b343a82..7570077a7 100644 --- a/netbox/extras/migrations/0109_script_model.py +++ b/netbox/extras/migrations/0109_script_model.py @@ -82,10 +82,12 @@ def update_scripts(apps, schema_editor): ContentType = apps.get_model('contenttypes', 'ContentType') Script = apps.get_model('extras', 'Script') ScriptModule = apps.get_model('extras', 'ScriptModule') + ReportModule = apps.get_model('extras', 'ReportModule') Job = apps.get_model('core', 'Job') - script_ct = ContentType.objects.get_for_model(Script) - scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule) + script_ct = ContentType.objects.get_for_model(Script, for_concrete_model=False) + scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule, for_concrete_model=False) + reportmodule_ct = ContentType.objects.get_for_model(ReportModule, for_concrete_model=False) for module in ScriptModule.objects.all(): for script_name in get_module_scripts(module): @@ -96,10 +98,16 @@ def update_scripts(apps, schema_editor): # Update all Jobs associated with this ScriptModule & script name to point to the new Script object Job.objects.filter( - object_type=scriptmodule_ct, + object_type_id=scriptmodule_ct.id, object_id=module.pk, name=script_name - ).update(object_type=script_ct, object_id=script.pk) + ).update(object_type_id=script_ct.id, object_id=script.pk) + # Update all Jobs associated with this ScriptModule & script name to point to the new Script object + Job.objects.filter( + object_type_id=reportmodule_ct.id, + object_id=module.pk, + name=script_name + ).update(object_type_id=script_ct.id, object_id=script.pk) def update_event_rules(apps, schema_editor): diff --git a/netbox/extras/migrations/0110_remove_eventrule_action_parameters.py b/netbox/extras/migrations/0110_remove_eventrule_action_parameters.py index 910352462..b7373bdce 100644 --- a/netbox/extras/migrations/0110_remove_eventrule_action_parameters.py +++ b/netbox/extras/migrations/0110_remove_eventrule_action_parameters.py @@ -12,4 +12,7 @@ class Migration(migrations.Migration): model_name='eventrule', name='action_parameters', ), + migrations.DeleteModel( + name='ReportModule', + ), ] diff --git a/netbox/extras/migrations/0111_rename_content_types.py b/netbox/extras/migrations/0111_rename_content_types.py new file mode 100644 index 000000000..7b0fa9459 --- /dev/null +++ b/netbox/extras/migrations/0111_rename_content_types.py @@ -0,0 +1,107 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_gfk_indexes'), + ('extras', '0110_remove_eventrule_action_parameters'), + ] + + operations = [ + # Custom fields + migrations.RenameField( + model_name='customfield', + old_name='content_types', + new_name='object_types', + ), + migrations.AlterField( + model_name='customfield', + name='object_types', + field=models.ManyToManyField(related_name='custom_fields', to='core.objecttype'), + ), + migrations.AlterField( + model_name='customfield', + name='object_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.objecttype'), + ), + migrations.RunSQL( + "ALTER TABLE extras_customfield_content_types_id_seq RENAME TO extras_customfield_object_types_id_seq" + ), + + # Custom links + migrations.RenameField( + model_name='customlink', + old_name='content_types', + new_name='object_types', + ), + migrations.AlterField( + model_name='customlink', + name='object_types', + field=models.ManyToManyField(related_name='custom_links', to='core.objecttype'), + ), + migrations.RunSQL( + "ALTER TABLE extras_customlink_content_types_id_seq RENAME TO extras_customlink_object_types_id_seq" + ), + + # Event rules + migrations.RenameField( + model_name='eventrule', + old_name='content_types', + new_name='object_types', + ), + migrations.AlterField( + model_name='eventrule', + name='object_types', + field=models.ManyToManyField(related_name='event_rules', to='core.objecttype'), + ), + migrations.RunSQL( + "ALTER TABLE extras_eventrule_content_types_id_seq RENAME TO extras_eventrule_object_types_id_seq" + ), + + # Export templates + migrations.RenameField( + model_name='exporttemplate', + old_name='content_types', + new_name='object_types', + ), + migrations.AlterField( + model_name='exporttemplate', + name='object_types', + field=models.ManyToManyField(related_name='export_templates', to='core.objecttype'), + ), + migrations.RunSQL( + "ALTER TABLE extras_exporttemplate_content_types_id_seq RENAME TO extras_exporttemplate_object_types_id_seq" + ), + + # Saved filters + migrations.RenameField( + model_name='savedfilter', + old_name='content_types', + new_name='object_types', + ), + migrations.AlterField( + model_name='savedfilter', + name='object_types', + field=models.ManyToManyField(related_name='saved_filters', to='core.objecttype'), + ), + migrations.RunSQL( + "ALTER TABLE extras_savedfilter_content_types_id_seq RENAME TO extras_savedfilter_object_types_id_seq" + ), + + # Image attachments + migrations.RemoveIndex( + model_name='imageattachment', + name='extras_imag_content_94728e_idx', + ), + migrations.RenameField( + model_name='imageattachment', + old_name='content_type', + new_name='object_type', + ), + migrations.AddIndex( + model_name='imageattachment', + index=models.Index(fields=['object_type', 'object_id'], name='extras_imag_object__96bebc_idx'), + ), + ] diff --git a/netbox/extras/migrations/0112_tag_update_object_types.py b/netbox/extras/migrations/0112_tag_update_object_types.py new file mode 100644 index 000000000..87ec117a4 --- /dev/null +++ b/netbox/extras/migrations/0112_tag_update_object_types.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_gfk_indexes'), + ('extras', '0111_rename_content_types'), + ] + + operations = [ + migrations.AlterField( + model_name='tag', + name='object_types', + field=models.ManyToManyField(blank=True, related_name='+', to='core.objecttype'), + ), + ] diff --git a/netbox/extras/migrations/0113_customfield_rename_object_type.py b/netbox/extras/migrations/0113_customfield_rename_object_type.py new file mode 100644 index 000000000..73c4a2a61 --- /dev/null +++ b/netbox/extras/migrations/0113_customfield_rename_object_type.py @@ -0,0 +1,16 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0112_tag_update_object_types'), + ] + + operations = [ + migrations.RenameField( + model_name='customfield', + old_name='object_type', + new_name='related_object_type', + ), + ] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 0155849aa..ebcebc09a 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -5,7 +5,7 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from core.models import ContentType +from core.models import ObjectType from extras.choices import * from ..querysets import ObjectChangeQuerySet @@ -113,7 +113,7 @@ class ObjectChange(models.Model): super().clean() # Validate the assigned object type - if self.changed_object_type not in ContentType.objects.with_feature('change_logging'): + if self.changed_object_type not in ObjectType.objects.with_feature('change_logging'): raise ValidationError( _("Change logging is not supported for this object type ({type}).").format( type=self.changed_object_type diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 6ea2167c2..ff39c3e8b 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -11,7 +11,7 @@ from extras.querysets import ConfigContextQuerySet from netbox.config import get_config from netbox.registry import registry from netbox.models import ChangeLoggedModel -from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin +from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin from utilities.jinja2 import ConfigTemplateLoader from utilities.utils import deepmerge @@ -26,7 +26,7 @@ __all__ = ( # Config contexts # -class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel): +class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B @@ -210,7 +210,7 @@ class ConfigContextModel(models.Model): # Config templates # -class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): +class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): name = models.CharField( verbose_name=_('name'), max_length=100 diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index e78d1af23..a14c71c63 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -12,7 +12,7 @@ from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from core.models import ContentType +from core.models import ObjectType from extras.choices import * from extras.data import CHOICE_SETS from netbox.models import ChangeLoggedModel @@ -52,8 +52,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): """ Return all CustomFields assigned to the given model. """ - content_type = ContentType.objects.get_for_model(model._meta.concrete_model) - return self.get_queryset().filter(content_types=content_type) + content_type = ObjectType.objects.get_for_model(model._meta.concrete_model) + return self.get_queryset().filter(object_types=content_type) def get_defaults_for_model(self, model): """ @@ -66,8 +66,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): - content_types = models.ManyToManyField( - to='contenttypes.ContentType', + object_types = models.ManyToManyField( + to='core.ObjectType', related_name='custom_fields', help_text=_('The object(s) to which this field applies.') ) @@ -78,8 +78,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): default=CustomFieldTypeChoices.TYPE_TEXT, help_text=_('The type of data this custom field holds') ) - object_type = models.ForeignKey( - to='contenttypes.ContentType', + related_object_type = models.ForeignKey( + to='core.ObjectType', on_delete=models.PROTECT, blank=True, null=True, @@ -209,7 +209,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): objects = CustomFieldManager() clone_fields = ( - 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight', + 'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable', ) @@ -284,7 +284,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): """ Called when a CustomField has been renamed. Updates all assigned object data. """ - for ct in self.content_types.all(): + for ct in self.object_types.all(): model = ct.model_class() params = {f'custom_field_data__{old_name}__isnull': False} instances = model.objects.filter(**params) @@ -344,11 +344,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # Object fields must define an object_type; other fields must not if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT): - if not self.object_type: + if not self.related_object_type: raise ValidationError({ 'object_type': _("Object fields must define an object type.") }) - elif self.object_type: + elif self.related_object_type: raise ValidationError({ 'object_type': _( "{type} fields may not define an object type.") @@ -388,10 +388,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): except ValueError: return value if self.type == CustomFieldTypeChoices.TYPE_OBJECT: - model = self.object_type.model_class() + model = self.related_object_type.model_class() return model.objects.filter(pk=value).first() if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - model = self.object_type.model_class() + model = self.related_object_type.model_class() return model.objects.filter(pk__in=value) return value @@ -488,7 +488,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # Object elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: - model = self.object_type.model_class() + model = self.related_object_type.model_class() field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField field = field_class( queryset=model.objects.all(), @@ -498,7 +498,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # Multiple objects elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - model = self.object_type.model_class() + model = self.related_object_type.model_class() field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField field = field_class( queryset=model.objects.all(), diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 60bccd8f2..b55aaa11d 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -12,7 +12,7 @@ from django.utils.formats import date_format from django.utils.translation import gettext_lazy as _ from rest_framework.utils.encoders import JSONEncoder -from core.models import ContentType +from core.models import ObjectType from extras.choices import * from extras.conditions import ConditionSet from extras.constants import * @@ -43,9 +43,9 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged specific type of object is created, modified, or deleted. The action to be taken might entail transmitting a webhook or executing a custom script. """ - content_types = models.ManyToManyField( - to='contenttypes.ContentType', - related_name='eventrules', + object_types = models.ManyToManyField( + to='core.ObjectType', + related_name='event_rules', verbose_name=_('object types'), help_text=_("The object(s) to which this rule applies.") ) @@ -313,8 +313,8 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. """ - content_types = models.ManyToManyField( - to='contenttypes.ContentType', + object_types = models.ManyToManyField( + to='core.ObjectType', related_name='custom_links', help_text=_('The object type(s) to which this link applies.') ) @@ -359,7 +359,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): ) clone_fields = ( - 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', + 'object_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', ) class Meta: @@ -409,8 +409,8 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): - content_types = models.ManyToManyField( - to='contenttypes.ContentType', + object_types = models.ManyToManyField( + to='core.ObjectType', related_name='export_templates', help_text=_('The object type(s) to which this template applies.') ) @@ -448,7 +448,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change ) clone_fields = ( - 'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment', + 'object_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment', ) class Meta: @@ -518,8 +518,8 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): """ A set of predefined keyword parameters that can be reused to filter for specific objects. """ - content_types = models.ManyToManyField( - to='contenttypes.ContentType', + object_types = models.ManyToManyField( + to='core.ObjectType', related_name='saved_filters', help_text=_('The object type(s) to which this filter applies.') ) @@ -561,7 +561,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): ) clone_fields = ( - 'content_types', 'weight', 'enabled', 'parameters', + 'object_types', 'weight', 'enabled', 'parameters', ) class Meta: @@ -598,13 +598,13 @@ class ImageAttachment(ChangeLoggedModel): """ An uploaded image which is associated with an object. """ - content_type = models.ForeignKey( + object_type = models.ForeignKey( to='contenttypes.ContentType', on_delete=models.CASCADE ) object_id = models.PositiveBigIntegerField() parent = GenericForeignKey( - ct_field='content_type', + ct_field='object_type', fk_field='object_id' ) image = models.ImageField( @@ -626,12 +626,12 @@ class ImageAttachment(ChangeLoggedModel): objects = RestrictedQuerySet.as_manager() - clone_fields = ('content_type', 'object_id') + clone_fields = ('object_type', 'object_id') class Meta: ordering = ('name', 'pk') # name may be non-unique indexes = ( - models.Index(fields=('content_type', 'object_id')), + models.Index(fields=('object_type', 'object_id')), ) verbose_name = _('image attachment') verbose_name_plural = _('image attachments') @@ -646,9 +646,9 @@ class ImageAttachment(ChangeLoggedModel): super().clean() # Validate the assigned object type - if self.content_type not in ContentType.objects.with_feature('image_attachments'): + if self.object_type not in ObjectType.objects.with_feature('image_attachments'): raise ValidationError( - _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.content_type) + _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.object_type) ) def delete(self, *args, **kwargs): @@ -739,7 +739,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat super().clean() # Validate the assigned object type - if self.assigned_object_type not in ContentType.objects.with_feature('journaling'): + if self.assigned_object_type not in ObjectType.objects.with_feature('journaling'): raise ValidationError( _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type) ) @@ -795,7 +795,7 @@ class Bookmark(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ContentType.objects.with_feature('bookmarks'): + if self.object_type not in ObjectType.objects.with_feature('bookmarks'): raise ValidationError( _("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type) ) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 3aba6df60..27b05638e 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -34,7 +34,7 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): blank=True, ) object_types = models.ManyToManyField( - to='contenttypes.ContentType', + to='core.ObjectType', related_name='+', blank=True, help_text=_("The object type(s) to which this this tag can be applied.") diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index f8dc204e7..833ce0036 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -8,6 +8,7 @@ from django.dispatch import receiver, Signal from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates +from core.models import ObjectType from core.signals import job_end, job_start from extras.constants import EVENT_JOB_END, EVENT_JOB_START from extras.events import process_event_rules @@ -205,13 +206,13 @@ def handle_cf_deleted(instance, **kwargs): """ Handle the cleanup of old custom field data when a CustomField is deleted. """ - instance.remove_stale_data(instance.content_types.all()) + instance.remove_stale_data(instance.object_types.all()) post_save.connect(handle_cf_renamed, sender=CustomField) pre_delete.connect(handle_cf_deleted, sender=CustomField) -m2m_changed.connect(handle_cf_added_obj_types, sender=CustomField.content_types.through) -m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through) +m2m_changed.connect(handle_cf_added_obj_types, sender=CustomField.object_types.through) +m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.object_types.through) # @@ -240,8 +241,8 @@ def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs): """ if action != 'pre_add': return - ct = ContentType.objects.get_for_model(instance) - # Retrieve any applied Tags that are restricted to certain object_types + ct = ObjectType.objects.get_for_model(instance) + # Retrieve any applied Tags that are restricted to certain object types for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'): if ct not in tag.object_types.all(): raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.") @@ -256,7 +257,7 @@ def process_job_start_event_rules(sender, **kwargs): """ Process event rules for jobs starting. """ - event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, content_types=sender.object_type) + event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, object_types=sender.object_type) username = sender.user.username if sender.user else None process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username) @@ -266,6 +267,6 @@ def process_job_end_event_rules(sender, **kwargs): """ Process event rules for jobs terminating. """ - event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, content_types=sender.object_type) + event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, object_types=sender.object_type) username = sender.user.username if sender.user else None process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 8482c5e24..a0f504931 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -5,7 +5,7 @@ from django.conf import settings from django.utils.translation import gettext_lazy as _ from extras.models import * -from netbox.tables import NetBoxTable, columns +from netbox.tables import BaseTable, NetBoxTable, columns from .template_code import * __all__ = ( @@ -21,6 +21,8 @@ __all__ = ( 'JournalEntryTable', 'ObjectChangeTable', 'SavedFilterTable', + 'ReportResultsTable', + 'ScriptResultsTable', 'TaggedItemTable', 'TagTable', 'WebhookTable', @@ -40,8 +42,8 @@ class CustomFieldTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - content_types = columns.ContentTypesColumn( - verbose_name=_('Content Types') + object_types = columns.ContentTypesColumn( + verbose_name=_('Object Types') ) required = columns.BooleanColumn( verbose_name=_('Required') @@ -55,6 +57,9 @@ class CustomFieldTable(NetBoxTable): description = columns.MarkdownColumn( verbose_name=_('Description') ) + related_object_type = columns.ContentTypeColumn( + verbose_name=_('Related Object Type') + ) choice_set = tables.Column( linkify=True, verbose_name=_('Choice Set') @@ -71,11 +76,11 @@ class CustomFieldTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = CustomField fields = ( - 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description', - 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', 'weight', 'choice_set', - 'choices', 'created', 'last_updated', + 'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required', + 'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', + 'weight', 'choice_set', 'choices', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') + default_columns = ('pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'description') class CustomFieldChoiceSetTable(NetBoxTable): @@ -115,8 +120,8 @@ class CustomLinkTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - content_types = columns.ContentTypesColumn( - verbose_name=_('Content Types'), + object_types = columns.ContentTypesColumn( + verbose_name=_('Object Types'), ) enabled = columns.BooleanColumn( verbose_name=_('Enabled'), @@ -128,10 +133,10 @@ class CustomLinkTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = CustomLink fields = ( - 'pk', 'id', 'name', 'content_types', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', + 'pk', 'id', 'name', 'object_types', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window') + default_columns = ('pk', 'name', 'object_types', 'enabled', 'group_name', 'button_class', 'new_window') class ExportTemplateTable(NetBoxTable): @@ -139,8 +144,8 @@ class ExportTemplateTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - content_types = columns.ContentTypesColumn( - verbose_name=_('Content Types'), + object_types = columns.ContentTypesColumn( + verbose_name=_('Object Types'), ) as_attachment = columns.BooleanColumn( verbose_name=_('As Attachment'), @@ -161,11 +166,11 @@ class ExportTemplateTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ExportTemplate fields = ( - 'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', + 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced', + 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced', ) @@ -174,8 +179,8 @@ class ImageAttachmentTable(NetBoxTable): verbose_name=_('ID'), linkify=False ) - content_type = columns.ContentTypeColumn( - verbose_name=_('Content Type'), + object_type = columns.ContentTypeColumn( + verbose_name=_('Object Type'), ) parent = tables.Column( verbose_name=_('Parent'), @@ -193,10 +198,10 @@ class ImageAttachmentTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ImageAttachment fields = ( - 'pk', 'content_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created', + 'pk', 'object_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created', 'last_updated', ) - default_columns = ('content_type', 'parent', 'image', 'name', 'size', 'created') + default_columns = ('object_type', 'parent', 'image', 'name', 'size', 'created') class SavedFilterTable(NetBoxTable): @@ -204,8 +209,8 @@ class SavedFilterTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - content_types = columns.ContentTypesColumn( - verbose_name=_('Content Types'), + object_types = columns.ContentTypesColumn( + verbose_name=_('Object Types'), ) enabled = columns.BooleanColumn( verbose_name=_('Enabled'), @@ -220,11 +225,11 @@ class SavedFilterTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = SavedFilter fields = ( - 'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared', + 'pk', 'id', 'name', 'slug', 'object_types', 'description', 'user', 'weight', 'enabled', 'shared', 'created', 'last_updated', 'parameters' ) default_columns = ( - 'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared', + 'pk', 'name', 'object_types', 'user', 'description', 'enabled', 'shared', ) @@ -281,8 +286,8 @@ class EventRuleTable(NetBoxTable): linkify=True, verbose_name=_('Object'), ) - content_types = columns.ContentTypesColumn( - verbose_name=_('Content Types'), + object_types = columns.ContentTypesColumn( + verbose_name=_('Object Types'), ) enabled = columns.BooleanColumn( verbose_name=_('Enabled'), @@ -309,12 +314,12 @@ class EventRuleTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = EventRule fields = ( - 'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'action_object', 'content_types', + 'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'action_object', 'object_types', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'enabled', 'action_type', 'action_object', 'content_types', 'type_create', 'type_update', + 'pk', 'name', 'enabled', 'action_type', 'action_object', 'object_types', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', ) @@ -507,3 +512,61 @@ class JournalEntryTable(NetBoxTable): default_columns = ( 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments' ) + + +class ScriptResultsTable(BaseTable): + index = tables.Column( + verbose_name=_('Line') + ) + time = tables.Column( + verbose_name=_('Time') + ) + status = tables.TemplateColumn( + template_code="""{% load log_levels %}{% log_level record.status %}""", + verbose_name=_('Level') + ) + message = tables.Column( + verbose_name=_('Message') + ) + + class Meta(BaseTable.Meta): + empty_text = _('No results found') + fields = ( + 'index', 'time', 'status', 'message', + ) + + +class ReportResultsTable(BaseTable): + index = tables.Column( + verbose_name=_('Line') + ) + method = tables.Column( + verbose_name=_('Method') + ) + time = tables.Column( + verbose_name=_('Time') + ) + status = tables.Column( + empty_values=(), + verbose_name=_('Level') + ) + status = tables.TemplateColumn( + template_code="""{% load log_levels %}{% log_level record.status %}""", + verbose_name=_('Level') + ) + + object = tables.Column( + verbose_name=_('Object') + ) + url = tables.Column( + verbose_name=_('URL') + ) + message = tables.Column( + verbose_name=_('Message') + ) + + class Meta(BaseTable.Meta): + empty_text = _('No results found') + fields = ( + 'index', 'method', 'time', 'status', 'object', 'url', 'message', + ) diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 5de95b607..31cd22815 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -1,7 +1,7 @@ from django import template -from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe +from core.models import ObjectType from extras.models import CustomLink @@ -32,8 +32,8 @@ def custom_links(context, obj): """ Render all applicable links for the given object. """ - content_type = ContentType.objects.get_for_model(obj) - custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True) + object_type = ObjectType.objects.get_for_model(obj) + custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True) if not custom_links: return '' diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 5db906b25..5d243ae1a 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -7,10 +7,10 @@ from django.utils.timezone import make_aware from rest_framework import status from core.choices import ManagedFileRootPathChoices +from core.models import ObjectType from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site from extras.choices import * from extras.models import * -from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar from utilities.testing import APITestCase, APIViewTestCases @@ -122,7 +122,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase): cls.create_data = [ { 'name': 'EventRule 4', - 'content_types': ['dcim.device', 'dcim.devicetype'], + 'object_types': ['dcim.device', 'dcim.devicetype'], 'type_create': True, 'action_type': EventRuleActionChoices.WEBHOOK, 'action_object_type': 'extras.webhook', @@ -130,7 +130,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase): }, { 'name': 'EventRule 5', - 'content_types': ['dcim.device', 'dcim.devicetype'], + 'object_types': ['dcim.device', 'dcim.devicetype'], 'type_create': True, 'action_type': EventRuleActionChoices.WEBHOOK, 'action_object_type': 'extras.webhook', @@ -138,7 +138,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase): }, { 'name': 'EventRule 6', - 'content_types': ['dcim.device', 'dcim.devicetype'], + 'object_types': ['dcim.device', 'dcim.devicetype'], 'type_create': True, 'action_type': EventRuleActionChoices.WEBHOOK, 'action_object_type': 'extras.webhook', @@ -152,17 +152,17 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): brief_fields = ['description', 'display', 'id', 'name', 'url'] create_data = [ { - 'content_types': ['dcim.site'], + 'object_types': ['dcim.site'], 'name': 'cf4', 'type': 'date', }, { - 'content_types': ['dcim.site'], + 'object_types': ['dcim.site'], 'name': 'cf5', 'type': 'url', }, { - 'content_types': ['dcim.site'], + 'object_types': ['dcim.site'], 'name': 'cf6', 'type': 'text', }, @@ -171,14 +171,14 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): 'description': 'New description', } update_data = { - 'content_types': ['dcim.device'], + 'object_types': ['dcim.device'], 'name': 'New_Name', 'description': 'New description', } @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) + site_ct = ObjectType.objects.get_for_model(Site) custom_fields = ( CustomField( @@ -196,7 +196,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): ) CustomField.objects.bulk_create(custom_fields) for cf in custom_fields: - cf.content_types.add(site_ct) + cf.object_types.add(site_ct) class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase): @@ -273,21 +273,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { - 'content_types': ['dcim.site'], + 'object_types': ['dcim.site'], 'name': 'Custom Link 4', 'enabled': True, 'link_text': 'Link 4', 'link_url': 'http://example.com/?4', }, { - 'content_types': ['dcim.site'], + 'object_types': ['dcim.site'], 'name': 'Custom Link 5', 'enabled': True, 'link_text': 'Link 5', 'link_url': 'http://example.com/?5', }, { - 'content_types': ['dcim.site'], + 'object_types': ['dcim.site'], 'name': 'Custom Link 6', 'enabled': False, 'link_text': 'Link 6', @@ -301,7 +301,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) + site_type = ObjectType.objects.get_for_model(Site) custom_links = ( CustomLink( @@ -325,7 +325,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): ) CustomLink.objects.bulk_create(custom_links) for i, custom_link in enumerate(custom_links): - custom_link.content_types.set([site_ct]) + custom_link.object_types.set([site_type]) class SavedFilterTest(APIViewTestCases.APIViewTestCase): @@ -333,7 +333,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { - 'content_types': ['dcim.site'], + 'object_types': ['dcim.site'], 'name': 'Saved Filter 4', 'slug': 'saved-filter-4', 'weight': 100, @@ -342,7 +342,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): 'parameters': {'status': ['active']}, }, { - 'content_types': ['dcim.site'], + 'object_types': ['dcim.site'], 'name': 'Saved Filter 5', 'slug': 'saved-filter-5', 'weight': 200, @@ -351,7 +351,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): 'parameters': {'status': ['planned']}, }, { - 'content_types': ['dcim.site'], + 'object_types': ['dcim.site'], 'name': 'Saved Filter 6', 'slug': 'saved-filter-6', 'weight': 300, @@ -368,7 +368,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) + site_type = ObjectType.objects.get_for_model(Site) saved_filters = ( SavedFilter( @@ -398,7 +398,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): ) SavedFilter.objects.bulk_create(saved_filters) for i, savedfilter in enumerate(saved_filters): - savedfilter.content_types.set([site_ct]) + savedfilter.object_types.set([site_type]) class BookmarkTest( @@ -458,17 +458,17 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): brief_fields = ['description', 'display', 'id', 'name', 'url'] create_data = [ { - 'content_types': ['dcim.device'], + 'object_types': ['dcim.device'], 'name': 'Test Export Template 4', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_types': ['dcim.device'], + 'object_types': ['dcim.device'], 'name': 'Test Export Template 5', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_types': ['dcim.device'], + 'object_types': ['dcim.device'], 'name': 'Test Export Template 6', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, @@ -495,7 +495,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): ) ExportTemplate.objects.bulk_create(export_templates) for et in export_templates: - et.content_types.set([ContentType.objects.get_for_model(Device)]) + et.object_types.set([ObjectType.objects.get_for_model(Device)]) class TagTest(APIViewTestCases.APIViewTestCase): @@ -548,7 +548,7 @@ class ImageAttachmentTest( image_attachments = ( ImageAttachment( - content_type=ct, + object_type=ct, object_id=site.pk, name='Image Attachment 1', image='http://example.com/image1.png', @@ -556,7 +556,7 @@ class ImageAttachmentTest( image_width=100 ), ImageAttachment( - content_type=ct, + object_type=ct, object_id=site.pk, name='Image Attachment 2', image='http://example.com/image2.png', @@ -564,7 +564,7 @@ class ImageAttachmentTest( image_width=100 ), ImageAttachment( - content_type=ct, + object_type=ct, object_id=site.pk, name='Image Attachment 3', image='http://example.com/image3.png', @@ -876,17 +876,17 @@ class CreatedUpdatedFilterTest(APITestCase): self.assertEqual(response.data['results'][0]['id'], rack2.pk) -class ContentTypeTest(APITestCase): +class ObjectTypeTest(APITestCase): def test_list_objects(self): - contenttype_count = ContentType.objects.count() + object_type_count = ObjectType.objects.count() - response = self.client.get(reverse('extras-api:contenttype-list'), **self.header) + response = self.client.get(reverse('extras-api:objecttype-list'), **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(response.data['count'], contenttype_count) + self.assertEqual(response.data['count'], object_type_count) def test_get_object(self): - contenttype = ContentType.objects.first() + object_type = ObjectType.objects.first() - url = reverse('extras-api:contenttype-detail', kwargs={'pk': contenttype.pk}) + url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk}) self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK) diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index e144c5dee..d9d6f1f45 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -3,6 +3,7 @@ from django.test import override_settings from django.urls import reverse from rest_framework import status +from core.models import ObjectType from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.choices import * @@ -23,14 +24,14 @@ class ChangeLogViewTest(ModelViewTestCase): ) # Create a custom field on the Site model - ct = ContentType.objects.get_for_model(Site) + site_type = ObjectType.objects.get_for_model(Site) cf = CustomField( type=CustomFieldTypeChoices.TYPE_TEXT, name='cf1', required=False ) cf.save() - cf.content_types.set([ct]) + cf.object_types.set([site_type]) # Create a select custom field on the Site model cf_select = CustomField( @@ -40,7 +41,7 @@ class ChangeLogViewTest(ModelViewTestCase): choice_set=choice_set ) cf_select.save() - cf_select.content_types.set([ct]) + cf_select.object_types.set([site_type]) def test_create_object(self): tags = create_tags('Tag 1', 'Tag 2') @@ -275,14 +276,14 @@ class ChangeLogAPITest(APITestCase): def setUpTestData(cls): # Create a custom field on the Site model - ct = ContentType.objects.get_for_model(Site) + site_type = ObjectType.objects.get_for_model(Site) cf = CustomField( type=CustomFieldTypeChoices.TYPE_TEXT, name='cf1', required=False ) cf.save() - cf.content_types.set([ct]) + cf.object_types.set([site_type]) # Create a select custom field on the Site model choice_set = CustomFieldChoiceSet.objects.create( @@ -296,7 +297,7 @@ class ChangeLogAPITest(APITestCase): choice_set=choice_set ) cf_select.save() - cf_select.content_types.set([ct]) + cf_select.object_types.set([site_type]) # Create some tags tags = ( diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 574452a81..0c8b86f93 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,11 +1,11 @@ import datetime from decimal import Decimal -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.urls import reverse from rest_framework import status +from core.models import ObjectType from dcim.filtersets import SiteFilterSet from dcim.forms import SiteImportForm from dcim.models import Manufacturer, Rack, Site @@ -28,7 +28,7 @@ class CustomFieldTest(TestCase): Site(name='Site C', slug='site-c'), ]) - cls.object_type = ContentType.objects.get_for_model(Site) + cls.object_type = ObjectType.objects.get_for_model(Site) def test_invalid_name(self): """ @@ -50,7 +50,7 @@ class CustomFieldTest(TestCase): type=CustomFieldTypeChoices.TYPE_TEXT, required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -75,7 +75,7 @@ class CustomFieldTest(TestCase): type=CustomFieldTypeChoices.TYPE_LONGTEXT, required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -99,7 +99,7 @@ class CustomFieldTest(TestCase): type=CustomFieldTypeChoices.TYPE_INTEGER, required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -125,7 +125,7 @@ class CustomFieldTest(TestCase): type=CustomFieldTypeChoices.TYPE_DECIMAL, required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -151,7 +151,7 @@ class CustomFieldTest(TestCase): type=CustomFieldTypeChoices.TYPE_INTEGER, required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -178,7 +178,7 @@ class CustomFieldTest(TestCase): type=CustomFieldTypeChoices.TYPE_DATE, required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -203,7 +203,7 @@ class CustomFieldTest(TestCase): type=CustomFieldTypeChoices.TYPE_DATETIME, required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -228,7 +228,7 @@ class CustomFieldTest(TestCase): type=CustomFieldTypeChoices.TYPE_URL, required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -253,7 +253,7 @@ class CustomFieldTest(TestCase): type=CustomFieldTypeChoices.TYPE_JSON, required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -290,7 +290,7 @@ class CustomFieldTest(TestCase): required=False, choice_set=choice_set ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -327,7 +327,7 @@ class CustomFieldTest(TestCase): required=False, choice_set=choice_set ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -350,10 +350,10 @@ class CustomFieldTest(TestCase): cf = CustomField.objects.create( name='object_field', type=CustomFieldTypeChoices.TYPE_OBJECT, - object_type=ContentType.objects.get_for_model(VLAN), + related_object_type=ObjectType.objects.get_for_model(VLAN), required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -382,10 +382,10 @@ class CustomFieldTest(TestCase): cf = CustomField.objects.create( name='object_field', type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, - object_type=ContentType.objects.get_for_model(VLAN), + related_object_type=ObjectType.objects.get_for_model(VLAN), required=False ) - cf.content_types.set([self.object_type]) + cf.object_types.set([self.object_type]) instance = Site.objects.first() self.assertIsNone(instance.custom_field_data[cf.name]) @@ -402,13 +402,13 @@ class CustomFieldTest(TestCase): self.assertIsNone(instance.custom_field_data.get(cf.name)) def test_rename_customfield(self): - obj_type = ContentType.objects.get_for_model(Site) + obj_type = ObjectType.objects.get_for_model(Site) FIELD_DATA = 'abc' # Create a custom field cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='field1') cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([obj_type]) # Assign custom field data to an object site = Site.objects.create( @@ -437,7 +437,7 @@ class CustomFieldTest(TestCase): ) ) site = Site.objects.create(name='Site 1', slug='site-1') - object_type = ContentType.objects.get_for_model(Site) + object_type = ObjectType.objects.get_for_model(Site) # Text CustomField(name='test', type='text', required=True, default="Default text").full_clean() @@ -498,16 +498,28 @@ class CustomFieldTest(TestCase): ).full_clean() # Object - CustomField(name='test', type='object', required=True, object_type=object_type, default=site.pk).full_clean() - with self.assertRaises(ValidationError): - CustomField(name='test', type='object', required=True, object_type=object_type, default="xxx").full_clean() + CustomField( + name='test', + type='object', + required=True, + related_object_type=object_type, + default=site.pk + ).full_clean() + with (self.assertRaises(ValidationError)): + CustomField( + name='test', + type='object', + required=True, + related_object_type=object_type, + default="xxx" + ).full_clean() # Multi-object CustomField( name='test', type='multiobject', required=True, - object_type=object_type, + related_object_type=object_type, default=[site.pk] ).full_clean() with self.assertRaises(ValidationError): @@ -515,7 +527,7 @@ class CustomFieldTest(TestCase): name='test', type='multiobject', required=True, - object_type=object_type, + related_object_type=object_type, default=["xxx"] ).full_clean() @@ -524,10 +536,10 @@ class CustomFieldManagerTest(TestCase): @classmethod def setUpTestData(cls): - content_type = ContentType.objects.get_for_model(Site) + object_type = ObjectType.objects.get_for_model(Site) custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') custom_field.save() - custom_field.content_types.set([content_type]) + custom_field.object_types.set([object_type]) def test_get_for_model(self): self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1) @@ -538,7 +550,7 @@ class CustomFieldAPITest(APITestCase): @classmethod def setUpTestData(cls): - content_type = ContentType.objects.get_for_model(Site) + object_type = ObjectType.objects.get_for_model(Site) # Create some VLANs vlans = ( @@ -581,19 +593,19 @@ class CustomFieldAPITest(APITestCase): CustomField( type=CustomFieldTypeChoices.TYPE_OBJECT, name='object_field', - object_type=ContentType.objects.get_for_model(VLAN), + related_object_type=ObjectType.objects.get_for_model(VLAN), default=vlans[0].pk, ), CustomField( type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, name='multiobject_field', - object_type=ContentType.objects.get_for_model(VLAN), + related_object_type=ObjectType.objects.get_for_model(VLAN), default=[vlans[0].pk, vlans[1].pk], ), ) for cf in custom_fields: cf.save() - cf.content_types.set([content_type]) + cf.object_types.set([object_type]) # Create some sites *after* creating the custom fields. This ensures that # default values are not set for the assigned objects. @@ -1163,7 +1175,7 @@ class CustomFieldImportTest(TestCase): ) for cf in custom_fields: cf.save() - cf.content_types.set([ContentType.objects.get_for_model(Site)]) + cf.object_types.set([ObjectType.objects.get_for_model(Site)]) def test_import(self): """ @@ -1256,11 +1268,11 @@ class CustomFieldModelTest(TestCase): def setUpTestData(cls): cf1 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='foo') cf1.save() - cf1.content_types.set([ContentType.objects.get_for_model(Site)]) + cf1.object_types.set([ObjectType.objects.get_for_model(Site)]) cf2 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='bar') cf2.save() - cf2.content_types.set([ContentType.objects.get_for_model(Rack)]) + cf2.object_types.set([ObjectType.objects.get_for_model(Rack)]) def test_cf_data(self): """ @@ -1299,7 +1311,7 @@ class CustomFieldModelTest(TestCase): """ cf3 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='baz', required=True) cf3.save() - cf3.content_types.set([ContentType.objects.get_for_model(Site)]) + cf3.object_types.set([ObjectType.objects.get_for_model(Site)]) site = Site(name='Test Site', slug='test-site') @@ -1318,7 +1330,7 @@ class CustomFieldModelFilterTest(TestCase): @classmethod def setUpTestData(cls): - obj_type = ContentType.objects.get_for_model(Site) + object_type = ObjectType.objects.get_for_model(Site) manufacturers = Manufacturer.objects.bulk_create(( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), @@ -1335,17 +1347,17 @@ class CustomFieldModelFilterTest(TestCase): # Integer filtering cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Decimal filtering cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Boolean filtering cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Exact text filtering cf = CustomField( @@ -1354,7 +1366,7 @@ class CustomFieldModelFilterTest(TestCase): filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT ) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Loose text filtering cf = CustomField( @@ -1363,12 +1375,12 @@ class CustomFieldModelFilterTest(TestCase): filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE ) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Date filtering cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Exact URL filtering cf = CustomField( @@ -1377,7 +1389,7 @@ class CustomFieldModelFilterTest(TestCase): filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT ) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Loose URL filtering cf = CustomField( @@ -1386,7 +1398,7 @@ class CustomFieldModelFilterTest(TestCase): filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE ) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Selection filtering cf = CustomField( @@ -1395,7 +1407,7 @@ class CustomFieldModelFilterTest(TestCase): choice_set=choice_set ) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Multiselect filtering cf = CustomField( @@ -1404,25 +1416,25 @@ class CustomFieldModelFilterTest(TestCase): choice_set=choice_set ) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Object filtering cf = CustomField( name='cf11', type=CustomFieldTypeChoices.TYPE_OBJECT, - object_type=ContentType.objects.get_for_model(Manufacturer) + related_object_type=ObjectType.objects.get_for_model(Manufacturer) ) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) # Multi-object filtering cf = CustomField( name='cf12', type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, - object_type=ContentType.objects.get_for_model(Manufacturer) + related_object_type=ObjectType.objects.get_for_model(Manufacturer) ) cf.save() - cf.content_types.set([obj_type]) + cf.object_types.set([object_type]) Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', custom_field_data={ diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py index 549c33478..8cea2078a 100644 --- a/netbox/extras/tests/test_event_rules.py +++ b/netbox/extras/tests/test_event_rules.py @@ -3,17 +3,18 @@ import uuid from unittest.mock import patch import django_rq -from dcim.choices import SiteStatusChoices -from dcim.models import Site -from django.contrib.contenttypes.models import ContentType from django.http import HttpResponse from django.urls import reverse +from requests import Session +from rest_framework import status + +from core.models import ObjectType +from dcim.choices import SiteStatusChoices +from dcim.models import Site from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices from extras.events import enqueue_object, flush_events, serialize_for_event from extras.models import EventRule, Tag, Webhook from extras.webhooks import generate_signature, send_webhook -from requests import Session -from rest_framework import status from utilities.testing import APITestCase @@ -29,7 +30,7 @@ class EventRuleTest(APITestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) + site_type = ObjectType.objects.get_for_model(Site) DUMMY_URL = 'http://localhost:9000/' DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING' @@ -39,32 +40,32 @@ class EventRuleTest(APITestCase): Webhook(name='Webhook 3', payload_url=DUMMY_URL, secret=DUMMY_SECRET), )) - ct = ContentType.objects.get(app_label='extras', model='webhook') + webhook_type = ObjectType.objects.get(app_label='extras', model='webhook') event_rules = EventRule.objects.bulk_create(( EventRule( name='Webhook Event 1', type_create=True, action_type=EventRuleActionChoices.WEBHOOK, - action_object_type=ct, + action_object_type=webhook_type, action_object_id=webhooks[0].id ), EventRule( name='Webhook Event 2', type_update=True, action_type=EventRuleActionChoices.WEBHOOK, - action_object_type=ct, + action_object_type=webhook_type, action_object_id=webhooks[0].id ), EventRule( name='Webhook Event 3', type_delete=True, action_type=EventRuleActionChoices.WEBHOOK, - action_object_type=ct, + action_object_type=webhook_type, action_object_id=webhooks[0].id ), )) for event_rule in event_rules: - event_rule.content_types.set([site_ct]) + event_rule.object_types.set([site_type]) Tag.objects.bulk_create(( Tag(name='Foo', slug='foo'), diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index ef8aedcbd..b68c02efc 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -7,6 +7,7 @@ from django.test import TestCase from circuits.models import Provider from core.choices import ManagedFileRootPathChoices +from core.models import ObjectType from dcim.filtersets import SiteFilterSet from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup from dcim.models import Location @@ -22,9 +23,10 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType User = get_user_model() -class CustomFieldTestCase(TestCase, BaseFilterSetTests): +class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CustomField.objects.all() filterset = CustomFieldFilterSet + ignore_fields = ('default',) @classmethod def setUpTestData(cls): @@ -85,13 +87,23 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): ui_editable=CustomFieldUIEditableChoices.HIDDEN, choice_set=choice_sets[1] ), + CustomField( + name='Custom Field 6', + type=CustomFieldTypeChoices.TYPE_OBJECT, + related_object_type=ObjectType.objects.get_by_natural_key('dcim', 'site'), + required=False, + weight=600, + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN + ), ) CustomField.objects.bulk_create(custom_fields) - custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site')) - custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack')) - custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) - custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) - custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) + custom_fields[0].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'site')) + custom_fields[1].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'rack')) + custom_fields[2].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'device')) + custom_fields[3].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'device')) + custom_fields[4].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'device')) def test_q(self): params = {'q': 'foobar1'} @@ -101,10 +113,16 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): params = {'name': ['Custom Field 1', 'Custom Field 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_types(self): - params = {'content_types': 'dcim.site'} + def test_object_type(self): + params = {'object_type': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]} + params = {'object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_related_object_type(self): + params = {'related_object_type': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'related_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_required(self): @@ -138,9 +156,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests): +class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CustomFieldChoiceSet.objects.all() filterset = CustomFieldChoiceSetFilterSet + ignore_fields = ('extra_choices',) @classmethod def setUpTestData(cls): @@ -171,11 +190,10 @@ class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests): class WebhookTestCase(TestCase, BaseFilterSetTests): queryset = Webhook.objects.all() filterset = WebhookFilterSet + ignore_fields = ('additional_headers', 'body_template') @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['region', 'site', 'rack', 'location', 'device']) - webhooks = ( Webhook( name='Webhook 1', @@ -237,10 +255,11 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): class EventRuleTestCase(TestCase, BaseFilterSetTests): queryset = EventRule.objects.all() filterset = EventRuleFilterSet + ignore_fields = ('action_data', 'conditions') @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter( + object_types = ObjectType.objects.filter( model__in=['region', 'site', 'rack', 'location', 'device'] ) @@ -333,11 +352,11 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests): ), ) EventRule.objects.bulk_create(event_rules) - event_rules[0].content_types.add(content_types[0]) - event_rules[1].content_types.add(content_types[1]) - event_rules[2].content_types.add(content_types[2]) - event_rules[3].content_types.add(content_types[3]) - event_rules[4].content_types.add(content_types[4]) + event_rules[0].object_types.add(object_types[0]) + event_rules[1].object_types.add(object_types[1]) + event_rules[2].object_types.add(object_types[2]) + event_rules[3].object_types.add(object_types[3]) + event_rules[4].object_types.add(object_types[4]) def test_q(self): params = {'q': 'foobar1'} @@ -351,10 +370,10 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_types(self): - params = {'content_types': 'dcim.region'} + def test_object_type(self): + params = {'object_type': 'dcim.region'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'content_type_id': [ContentType.objects.get_for_model(Region).pk]} + params = {'object_type_id': [ObjectType.objects.get_for_model(Region).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_action_type(self): @@ -390,13 +409,13 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class CustomLinkTestCase(TestCase, BaseFilterSetTests): +class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CustomLink.objects.all() filterset = CustomLinkFilterSet @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device']) custom_links = ( CustomLink( @@ -426,7 +445,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): ) CustomLink.objects.bulk_create(custom_links) for i, custom_link in enumerate(custom_links): - custom_link.content_types.set([content_types[i]]) + custom_link.object_types.set([object_types[i]]) def test_q(self): params = {'q': 'Custom Link 1'} @@ -436,10 +455,10 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): params = {'name': ['Custom Link 1', 'Custom Link 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_types(self): - params = {'content_types': 'dcim.site'} + def test_object_type(self): + params = {'object_type': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_weight(self): @@ -459,13 +478,14 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class SavedFilterTestCase(TestCase, BaseFilterSetTests): +class SavedFilterTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = SavedFilter.objects.all() filterset = SavedFilterFilterSet + ignore_fields = ('parameters',) @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device']) users = ( User(username='User 1'), @@ -508,7 +528,7 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests): ) SavedFilter.objects.bulk_create(saved_filters) for i, savedfilter in enumerate(saved_filters): - savedfilter.content_types.set([content_types[i]]) + savedfilter.object_types.set([object_types[i]]) def test_q(self): params = {'q': 'foobar1'} @@ -526,10 +546,10 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_types(self): - params = {'content_types': 'dcim.site'} + def test_object_type(self): + params = {'object_type': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_user(self): @@ -632,13 +652,14 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) -class ExportTemplateTestCase(TestCase, BaseFilterSetTests): +class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ExportTemplate.objects.all() filterset = ExportTemplateFilterSet + ignore_fields = ('template_code', 'data_path') @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device']) export_templates = ( ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'), @@ -647,7 +668,7 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests): ) ExportTemplate.objects.bulk_create(export_templates) for i, et in enumerate(export_templates): - et.content_types.set([content_types[i]]) + et.object_types.set([object_types[i]]) def test_q(self): params = {'q': 'foobar1'} @@ -657,10 +678,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests): params = {'name': ['Export Template 1', 'Export Template 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_types(self): - params = {'content_types': 'dcim.site'} + def test_object_type(self): + params = {'object_type': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_description(self): @@ -668,9 +689,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ImageAttachmentTestCase(TestCase, BaseFilterSetTests): +class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ImageAttachment.objects.all() filterset = ImageAttachmentFilterSet + ignore_fields = ('image',) @classmethod def setUpTestData(cls): @@ -692,7 +714,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests): image_attachments = ( ImageAttachment( - content_type=site_ct, + object_type=site_ct, object_id=sites[0].pk, name='Image Attachment 1', image='http://example.com/image1.png', @@ -700,7 +722,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests): image_width=100 ), ImageAttachment( - content_type=site_ct, + object_type=site_ct, object_id=sites[1].pk, name='Image Attachment 2', image='http://example.com/image2.png', @@ -708,7 +730,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests): image_width=100 ), ImageAttachment( - content_type=rack_ct, + object_type=rack_ct, object_id=racks[0].pk, name='Image Attachment 3', image='http://example.com/image3.png', @@ -716,7 +738,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests): image_width=100 ), ImageAttachment( - content_type=rack_ct, + object_type=rack_ct, object_id=racks[1].pk, name='Image Attachment 4', image='http://example.com/image4.png', @@ -734,23 +756,17 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests): params = {'name': ['Image Attachment 1', 'Image Attachment 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_type(self): - params = {'content_type': 'dcim.site'} + def test_object_type(self): + params = {'object_type': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_type_id_and_object_id(self): + def test_object_type_id_and_object_id(self): params = { - 'content_type_id': ContentType.objects.get(app_label='dcim', model='site').pk, + 'object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk, 'object_id': [Site.objects.first().pk], } self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_created(self): - pk_list = self.queryset.values_list('pk', flat=True)[:2] - self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) - params = {'created': '2021-01-01T00:00:00'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = JournalEntry.objects.all() @@ -858,6 +874,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests): class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConfigContext.objects.all() filterset = ConfigContextFilterSet + ignore_fields = ('data', 'data_path') @classmethod def setUpTestData(cls): @@ -1026,11 +1043,11 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_role(self): + def test_device_role(self): device_roles = DeviceRole.objects.all()[:2] - params = {'role_id': [device_roles[0].pk, device_roles[1].pk]} + params = {'device_role_id': [device_roles[0].pk, device_roles[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'role': [device_roles[0].slug, device_roles[1].slug]} + params = {'device_role': [device_roles[0].slug, device_roles[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_platform(self): @@ -1081,9 +1098,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConfigTemplateTestCase(TestCase, BaseFilterSetTests): +class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConfigTemplate.objects.all() filterset = ConfigTemplateFilterSet + ignore_fields = ('template_code', 'environment_params', 'data_path') @classmethod def setUpTestData(cls): @@ -1110,12 +1128,99 @@ class ConfigTemplateTestCase(TestCase, BaseFilterSetTests): class TagTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Tag.objects.all() filterset = TagFilterSet + ignore_fields = ( + 'object_types', + + # Reverse relationships (to tagged models) we can ignore + 'aggregate', + 'asn', + 'asnrange', + 'cable', + 'circuit', + 'circuittermination', + 'circuittype', + 'cluster', + 'clustergroup', + 'clustertype', + 'configtemplate', + 'consoleport', + 'consoleserverport', + 'contact', + 'contactassignment', + 'contactgroup', + 'contactrole', + 'datasource', + 'device', + 'devicebay', + 'devicerole', + 'devicetype', + 'dummymodel', # From dummy_plugin + 'eventrule', + 'fhrpgroup', + 'frontport', + 'ikepolicy', + 'ikeproposal', + 'interface', + 'inventoryitem', + 'inventoryitemrole', + 'ipaddress', + 'iprange', + 'ipsecpolicy', + 'ipsecprofile', + 'ipsecproposal', + 'journalentry', + 'l2vpn', + 'l2vpntermination', + 'location', + 'manufacturer', + 'module', + 'modulebay', + 'moduletype', + 'platform', + 'powerfeed', + 'poweroutlet', + 'powerpanel', + 'powerport', + 'prefix', + 'provider', + 'provideraccount', + 'providernetwork', + 'rack', + 'rackreservation', + 'rackrole', + 'rearport', + 'region', + 'rir', + 'role', + 'routetarget', + 'service', + 'servicetemplate', + 'site', + 'sitegroup', + 'tenant', + 'tenantgroup', + 'tunnel', + 'tunnelgroup', + 'tunneltermination', + 'virtualchassis', + 'virtualdevicecontext', + 'virtualdisk', + 'virtualmachine', + 'vlan', + 'vlangroup', + 'vminterface', + 'vrf', + 'webhook', + 'wirelesslan', + 'wirelesslangroup', + 'wirelesslink', + ) @classmethod def setUpTestData(cls): - content_types = { - 'site': ContentType.objects.get_by_natural_key('dcim', 'site'), - 'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'), + object_types = { + 'site': ObjectType.objects.get_by_natural_key('dcim', 'site'), + 'provider': ObjectType.objects.get_by_natural_key('circuits', 'provider'), } tags = ( @@ -1124,8 +1229,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): Tag(name='Tag 3', slug='tag-3', color='0000ff'), ) Tag.objects.bulk_create(tags) - tags[0].object_types.add(content_types['site']) - tags[1].object_types.add(content_types['provider']) + tags[0].object_types.add(object_types['site']) + tags[1].object_types.add(object_types['provider']) # Apply some tags so we can filter by content type site = Site.objects.create(name='Site 1', slug='site-1') @@ -1163,12 +1268,12 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_object_types(self): - params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]} + params = {'for_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]} self.assertEqual( list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)), ['Tag 1', 'Tag 3'] ) - params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('circuits', 'provider').pk]} + params = {'for_object_type_id': [ObjectType.objects.get_by_natural_key('circuits', 'provider').pk]} self.assertEqual( list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)), ['Tag 2', 'Tag 3'] @@ -1178,6 +1283,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): class ObjectChangeTestCase(TestCase, BaseFilterSetTests): queryset = ObjectChange.objects.all() filterset = ObjectChangeFilterSet + ignore_fields = ('prechange_data', 'postchange_data') @classmethod def setUpTestData(cls): diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 9c22bf83c..66c4e245e 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -1,6 +1,6 @@ -from django.contrib.contenttypes.models import ContentType from django.test import TestCase +from core.models import ObjectType from dcim.forms import SiteForm from dcim.models import Site from extras.choices import CustomFieldTypeChoices @@ -12,66 +12,66 @@ class CustomFieldModelFormTest(TestCase): @classmethod def setUpTestData(cls): - obj_type = ContentType.objects.get_for_model(Site) + object_type = ObjectType.objects.get_for_model(Site) choice_set = CustomFieldChoiceSet.objects.create( name='Choice Set 1', extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C')) ) cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT) - cf_text.content_types.set([obj_type]) + cf_text.object_types.set([object_type]) cf_longtext = CustomField.objects.create(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT) - cf_longtext.content_types.set([obj_type]) + cf_longtext.object_types.set([object_type]) cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER) - cf_integer.content_types.set([obj_type]) + cf_integer.object_types.set([object_type]) cf_integer = CustomField.objects.create(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL) - cf_integer.content_types.set([obj_type]) + cf_integer.object_types.set([object_type]) cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN) - cf_boolean.content_types.set([obj_type]) + cf_boolean.object_types.set([object_type]) cf_date = CustomField.objects.create(name='date', type=CustomFieldTypeChoices.TYPE_DATE) - cf_date.content_types.set([obj_type]) + cf_date.object_types.set([object_type]) cf_datetime = CustomField.objects.create(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME) - cf_datetime.content_types.set([obj_type]) + cf_datetime.object_types.set([object_type]) cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL) - cf_url.content_types.set([obj_type]) + cf_url.object_types.set([object_type]) cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON) - cf_json.content_types.set([obj_type]) + cf_json.object_types.set([object_type]) cf_select = CustomField.objects.create( name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set ) - cf_select.content_types.set([obj_type]) + cf_select.object_types.set([object_type]) cf_multiselect = CustomField.objects.create( name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set ) - cf_multiselect.content_types.set([obj_type]) + cf_multiselect.object_types.set([object_type]) cf_object = CustomField.objects.create( name='object', type=CustomFieldTypeChoices.TYPE_OBJECT, - object_type=ContentType.objects.get_for_model(Site) + related_object_type=ObjectType.objects.get_for_model(Site) ) - cf_object.content_types.set([obj_type]) + cf_object.object_types.set([object_type]) cf_multiobject = CustomField.objects.create( name='multiobject', type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, - object_type=ContentType.objects.get_for_model(Site) + related_object_type=ObjectType.objects.get_for_model(Site) ) - cf_multiobject.content_types.set([obj_type]) + cf_multiobject.object_types.set([object_type]) def test_empty_values(self): """ @@ -99,7 +99,7 @@ class SavedFilterFormTest(TestCase): form = SavedFilterForm({ 'name': 'test-sf', 'slug': 'test-sf', - 'content_types': [ContentType.objects.get_for_model(Site).pk], + 'object_types': [ObjectType.objects.get_for_model(Site).pk], 'weight': 100, 'parameters': { "status": [ diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index cb3f08acb..c92a1bc54 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,6 +1,6 @@ -from django.contrib.contenttypes.models import ContentType from django.test import TestCase +from core.models import ObjectType from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup @@ -22,7 +22,7 @@ class TagTest(TestCase): # Create a Tag that can only be applied to Regions tag = Tag.objects.create(name='Tag 1', slug='tag-1') - tag.object_types.add(ContentType.objects.get_by_natural_key('dcim', 'region')) + tag.object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'region')) # Apply the Tag to a Region region.tags.add(tag) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index d720560e4..fd478acd4 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse +from core.models import ObjectType from dcim.models import DeviceType, Manufacturer, Site from extras.choices import * from extras.models import * @@ -19,7 +20,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) + site_type = ObjectType.objects.get_for_model(Site) CustomFieldChoiceSet.objects.create( name='Choice Set 1', extra_choices=( @@ -36,13 +37,13 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) for customfield in custom_fields: customfield.save() - customfield.content_types.add(site_ct) + customfield.object_types.add(site_type) cls.form_data = { 'name': 'field_x', 'label': 'Field X', 'type': 'text', - 'content_types': [site_ct.pk], + 'object_types': [site_type.pk], 'search_weight': 2000, 'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT, 'default': None, @@ -53,7 +54,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable', + 'name,label,type,object_types,related_object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable', 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},always,yes', 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,always,yes', 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,always,yes', @@ -137,7 +138,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) + site_type = ObjectType.objects.get_for_model(Site) custom_links = ( CustomLink(name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1'), CustomLink(name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2'), @@ -145,11 +146,11 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) CustomLink.objects.bulk_create(custom_links) for i, custom_link in enumerate(custom_links): - custom_link.content_types.set([site_ct]) + custom_link.object_types.set([site_type]) cls.form_data = { 'name': 'Custom Link X', - 'content_types': [site_ct.pk], + 'object_types': [site_type.pk], 'enabled': False, 'weight': 100, 'button_class': CustomLinkButtonClassChoices.DEFAULT, @@ -158,7 +159,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,content_types,enabled,weight,button_class,link_text,link_url", + "name,object_types,enabled,weight,button_class,link_text,link_url", "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4", "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5", "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6", @@ -183,7 +184,7 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) + site_type = ObjectType.objects.get_for_model(Site) users = ( User(username='User 1'), @@ -217,12 +218,12 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) SavedFilter.objects.bulk_create(saved_filters) for i, savedfilter in enumerate(saved_filters): - savedfilter.content_types.set([site_ct]) + savedfilter.object_types.set([site_type]) cls.form_data = { 'name': 'Saved Filter X', 'slug': 'saved-filter-x', - 'content_types': [site_ct.pk], + 'object_types': [site_type.pk], 'description': 'Foo', 'weight': 1000, 'enabled': True, @@ -231,7 +232,7 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,slug,content_types,weight,enabled,shared,parameters', + 'name,slug,object_types,weight,enabled,shared,parameters', 'Saved Filter 4,saved-filter-4,dcim.device,400,True,True,{"foo": "a"}', 'Saved Filter 5,saved-filter-5,dcim.device,500,True,True,{"foo": "b"}', 'Saved Filter 6,saved-filter-6,dcim.device,600,True,True,{"foo": "c"}', @@ -302,7 +303,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) + site_type = ObjectType.objects.get_for_model(Site) TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}""" export_templates = ( @@ -312,16 +313,16 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) ExportTemplate.objects.bulk_create(export_templates) for et in export_templates: - et.content_types.set([site_ct]) + et.object_types.set([site_type]) cls.form_data = { 'name': 'Export Template X', - 'content_types': [site_ct.pk], + 'object_types': [site_type.pk], 'template_code': TEMPLATE_CODE, } cls.csv_data = ( - "name,content_types,template_code", + "name,object_types,template_code", f"Export Template 4,dcim.site,{TEMPLATE_CODE}", f"Export Template 5,dcim.site,{TEMPLATE_CODE}", f"Export Template 6,dcim.site,{TEMPLATE_CODE}", @@ -396,7 +397,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase): for webhook in webhooks: webhook.save() - site_ct = ContentType.objects.get_for_model(Site) + site_type = ObjectType.objects.get_for_model(Site) event_rules = ( EventRule(name='EventRule 1', type_create=True, action_object=webhooks[0]), EventRule(name='EventRule 2', type_create=True, action_object=webhooks[1]), @@ -404,12 +405,12 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) for event in event_rules: event.save() - event.content_types.add(site_ct) + event.object_types.add(site_type) webhook_ct = ContentType.objects.get_for_model(Webhook) cls.form_data = { 'name': 'Event X', - 'content_types': [site_ct.pk], + 'object_types': [site_type.pk], 'type_create': False, 'type_update': True, 'type_delete': True, @@ -422,7 +423,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,content_types,type_create,action_type,action_object", + "name,object_types,type_create,action_type,action_object", "Webhook 4,dcim.site,True,webhook,Webhook 1", ) @@ -651,7 +652,7 @@ class CustomLinkTest(TestCase): new_window=False ) customlink.save() - customlink.content_types.set([ContentType.objects.get_for_model(Site)]) + customlink.object_types.set([ObjectType.objects.get_for_model(Site)]) site = Site(name='Test Site', slug='test-site') site.save() diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 4464af718..e67b9b50c 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -24,7 +24,7 @@ def image_upload(instance, filename): elif instance.name: filename = instance.name - return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) + return '{}{}_{}_{}'.format(path, instance.object_type.name, instance.object_id, filename) def is_script(obj): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 4a3be53bc..534153886 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -17,6 +17,7 @@ from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic +from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm, get_field_value from utilities.htmx import render_partial from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -27,6 +28,7 @@ from utilities.views import ContentTypePermissionRequiredMixin, register_model_v from . import filtersets, forms, tables from .models import * from .scripts import run_script +from .tables import ReportResultsTable, ScriptResultsTable # @@ -47,9 +49,9 @@ class CustomFieldView(generic.ObjectView): def get_extra_context(self, request, instance): related_models = () - for content_type in instance.content_types.all(): + for object_type in instance.object_types.all(): related_models += ( - content_type.model_class().objects.restrict(request.user, 'view').exclude( + object_type.model_class().objects.restrict(request.user, 'view').exclude( Q(**{f'custom_field_data__{instance.name}': ''}) | Q(**{f'custom_field_data__{instance.name}': None}) ), @@ -763,8 +765,8 @@ class ImageAttachmentEditView(generic.ObjectEditView): def alter_object(self, instance, request, args, kwargs): if not instance.pk: # Assign the parent object based on URL kwargs - content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type')) - instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) + object_type = get_object_or_404(ContentType, pk=request.GET.get('object_type')) + instance.parent = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id')) return instance def get_return_url(self, request, obj=None): @@ -772,7 +774,7 @@ class ImageAttachmentEditView(generic.ObjectEditView): def get_extra_addanother_params(self, request): return { - 'content_type': request.GET.get('content_type'), + 'object_type': request.GET.get('object_type'), 'object_id': request.GET.get('object_id'), } @@ -1144,19 +1146,72 @@ class LegacyScriptRedirectView(ContentTypePermissionRequiredMixin, View): return redirect(f'{url}{path}') -class ScriptResultView(generic.ObjectView): +class ScriptResultView(TableMixin, generic.ObjectView): queryset = Job.objects.all() def get_required_permission(self): return 'extras.view_script' + def get_table(self, job, request, bulk_actions=True): + data = [] + tests = None + table = None + index = 0 + if job.data: + if 'log' in job.data: + if 'tests' in job.data: + tests = job.data['tests'] + + for log in job.data['log']: + index += 1 + result = { + 'index': index, + 'time': log.get('time'), + 'status': log.get('status'), + 'message': log.get('message'), + } + data.append(result) + + table = ScriptResultsTable(data, user=request.user) + table.configure(request) + else: + # for legacy reports + tests = job.data + + if tests: + for method, test_data in tests.items(): + if 'log' in test_data: + for time, status, obj, url, message in test_data['log']: + index += 1 + result = { + 'index': index, + 'method': method, + 'time': time, + 'status': status, + 'object': obj, + 'url': url, + 'message': message, + } + data.append(result) + + table = ReportResultsTable(data, user=request.user) + table.configure(request) + + return table + def get(self, request, **kwargs): + table = None job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk')) + if job.completed: + table = self.get_table(job, request, bulk_actions=False) + context = { 'script': job.object, 'job': job, + 'table': table, } + if job.data and 'log' in job.data: # Script context['tests'] = job.data.get('tests', {}) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index a9c0ce7cb..1f5f21028 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,510 +1,8 @@ -from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers - -from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer -from ipam.choices import * -from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES -from ipam.models import * -from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField -from netbox.api.serializers import NetBoxModelSerializer -from netbox.constants import NESTED_SERIALIZER_PREFIX -from tenancy.api.nested_serializers import NestedTenantSerializer -from utilities.api import get_serializer_for_model -from virtualization.api.nested_serializers import NestedVirtualMachineSerializer -from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer -from .field_serializers import IPAddressField, IPNetworkField +from .serializers_.asns import * +from .serializers_.vrfs import * +from .serializers_.roles import * +from .serializers_.vlans import * +from .serializers_.ip import * +from .serializers_.fhrpgroups import * +from .serializers_.services import * from .nested_serializers import * - - -# -# ASN ranges -# - -class ASNRangeSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asnrange-detail') - rir = NestedRIRSerializer() - tenant = NestedTenantSerializer(required=False, allow_null=True) - asn_count = serializers.IntegerField(read_only=True) - - class Meta: - model = ASNRange - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'asn_count', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - -# -# ASNs -# - -class ASNSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') - rir = NestedRIRSerializer(required=False, allow_null=True) - tenant = NestedTenantSerializer(required=False, allow_null=True) - - # Related object counts - site_count = RelatedObjectCountField('sites') - provider_count = RelatedObjectCountField('providers') - - class Meta: - model = ASN - fields = [ - 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', 'provider_count', - ] - brief_fields = ('id', 'url', 'display', 'asn', 'description') - - -class AvailableASNSerializer(serializers.Serializer): - """ - Representation of an ASN which does not exist in the database. - """ - asn = serializers.IntegerField(read_only=True) - description = serializers.CharField(required=False) - - def to_representation(self, asn): - rir = NestedRIRSerializer(self.context['range'].rir, context={ - 'request': self.context['request'] - }).data - return { - 'rir': rir, - 'asn': asn, - } - - -# -# VRFs -# - -class VRFSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') - tenant = NestedTenantSerializer(required=False, allow_null=True) - import_targets = SerializedPKRelatedField( - queryset=RouteTarget.objects.all(), - serializer=NestedRouteTargetSerializer, - required=False, - many=True - ) - export_targets = SerializedPKRelatedField( - queryset=RouteTarget.objects.all(), - serializer=NestedRouteTargetSerializer, - required=False, - many=True - ) - - # Related object counts - ipaddress_count = RelatedObjectCountField('ip_addresses') - prefix_count = RelatedObjectCountField('prefixes') - - class Meta: - model = VRF - fields = [ - 'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', - 'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', - 'prefix_count', - ] - brief_fields = ('id', 'url', 'display', 'name', 'rd', 'description', 'prefix_count') - - -# -# Route targets -# - -class RouteTargetSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail') - tenant = NestedTenantSerializer(required=False, allow_null=True) - - class Meta: - model = RouteTarget - fields = [ - 'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'description') - - -# -# RIRs/aggregates -# - -class RIRSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') - - # Related object counts - aggregate_count = RelatedObjectCountField('aggregates') - - class Meta: - model = RIR - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', 'aggregate_count', - ] - brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'aggregate_count') - - -class AggregateSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') - family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) - rir = NestedRIRSerializer() - tenant = NestedTenantSerializer(required=False, allow_null=True) - prefix = IPNetworkField() - - class Meta: - model = Aggregate - fields = [ - 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', - 'tags', 'custom_fields', 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description') - - -# -# FHRP Groups -# - -class FHRPGroupSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail') - ip_addresses = NestedIPAddressSerializer(many=True, read_only=True) - - class Meta: - model = FHRPGroup - fields = [ - 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments', - 'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses', - ] - brief_fields = ('id', 'url', 'display', 'protocol', 'group_id', 'description') - - -class FHRPGroupAssignmentSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail') - group = NestedFHRPGroupSerializer() - interface_type = ContentTypeField( - queryset=ContentType.objects.all() - ) - interface = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = FHRPGroupAssignment - fields = [ - 'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created', - 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_interface(self, obj): - if obj.interface is None: - return None - serializer = get_serializer_for_model(obj.interface, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.interface, context=context).data - - -# -# VLANs -# - -class RoleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') - - # Related object counts - prefix_count = RelatedObjectCountField('prefixes') - vlan_count = RelatedObjectCountField('vlans') - - class Meta: - model = Role - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', 'prefix_count', 'vlan_count', - ] - brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count') - - -class VLANGroupSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') - scope_type = ContentTypeField( - queryset=ContentType.objects.filter( - model__in=VLANGROUP_SCOPE_TYPES - ), - allow_null=True, - required=False, - default=None - ) - scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) - scope = serializers.SerializerMethodField(read_only=True) - utilization = serializers.CharField(read_only=True) - - # Related object counts - vlan_count = RelatedObjectCountField('vlans') - - class Meta: - model = VLANGroup - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' - ] - brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count') - validators = [] - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_scope(self, obj): - if obj.scope_id is None: - return None - serializer = get_serializer_for_model(obj.scope, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - - return serializer(obj.scope, context=context).data - - -class VLANSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') - site = NestedSiteSerializer(required=False, allow_null=True) - group = NestedVLANGroupSerializer(required=False, allow_null=True, default=None) - tenant = NestedTenantSerializer(required=False, allow_null=True) - status = ChoiceField(choices=VLANStatusChoices, required=False) - role = NestedRoleSerializer(required=False, allow_null=True) - l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True) - - # Related object counts - prefix_count = RelatedObjectCountField('prefixes') - - class Meta: - model = VLAN - fields = [ - 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', - 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', - ] - brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description') - - -class AvailableVLANSerializer(serializers.Serializer): - """ - Representation of a VLAN which does not exist in the database. - """ - vid = serializers.IntegerField(read_only=True) - group = NestedVLANGroupSerializer(read_only=True) - - def to_representation(self, instance): - return { - 'vid': instance, - 'group': NestedVLANGroupSerializer( - self.context['group'], - context={'request': self.context['request']} - ).data, - } - - -class CreateAvailableVLANSerializer(NetBoxModelSerializer): - site = NestedSiteSerializer(required=False, allow_null=True) - tenant = NestedTenantSerializer(required=False, allow_null=True) - status = ChoiceField(choices=VLANStatusChoices, required=False) - role = NestedRoleSerializer(required=False, allow_null=True) - - class Meta: - model = VLAN - fields = [ - 'name', 'site', 'tenant', 'status', 'role', 'description', 'tags', 'custom_fields', - ] - - def validate(self, data): - # Bypass model validation since we don't have a VID yet - return data - - -# -# Prefixes -# - -class PrefixSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') - family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) - site = NestedSiteSerializer(required=False, allow_null=True) - vrf = NestedVRFSerializer(required=False, allow_null=True) - tenant = NestedTenantSerializer(required=False, allow_null=True) - vlan = NestedVLANSerializer(required=False, allow_null=True) - status = ChoiceField(choices=PrefixStatusChoices, required=False) - role = NestedRoleSerializer(required=False, allow_null=True) - children = serializers.IntegerField(read_only=True) - _depth = serializers.IntegerField(read_only=True) - prefix = IPNetworkField() - - class Meta: - model = Prefix - fields = [ - 'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', - 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', - '_depth', - ] - brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth') - - -class PrefixLengthSerializer(serializers.Serializer): - - prefix_length = serializers.IntegerField() - - def to_internal_value(self, data): - requested_prefix = data.get('prefix_length') - if requested_prefix is None: - raise serializers.ValidationError({ - 'prefix_length': 'this field can not be missing' - }) - if not isinstance(requested_prefix, int): - raise serializers.ValidationError({ - 'prefix_length': 'this field must be int type' - }) - - prefix = self.context.get('prefix') - if prefix.family == 4 and requested_prefix > 32: - raise serializers.ValidationError({ - 'prefix_length': 'Invalid prefix length ({}) for IPv4'.format((requested_prefix)) - }) - elif prefix.family == 6 and requested_prefix > 128: - raise serializers.ValidationError({ - 'prefix_length': 'Invalid prefix length ({}) for IPv6'.format((requested_prefix)) - }) - return data - - -class AvailablePrefixSerializer(serializers.Serializer): - """ - Representation of a prefix which does not exist in the database. - """ - family = serializers.IntegerField(read_only=True) - prefix = serializers.CharField(read_only=True) - vrf = NestedVRFSerializer(read_only=True) - - def to_representation(self, instance): - if self.context.get('vrf'): - vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data - else: - vrf = None - return { - 'family': instance.version, - 'prefix': str(instance), - 'vrf': vrf, - } - - -# -# IP ranges -# - -class IPRangeSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:iprange-detail') - family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) - start_address = IPAddressField() - end_address = IPAddressField() - vrf = NestedVRFSerializer(required=False, allow_null=True) - tenant = NestedTenantSerializer(required=False, allow_null=True) - status = ChoiceField(choices=IPRangeStatusChoices, required=False) - role = NestedRoleSerializer(required=False, allow_null=True) - - class Meta: - model = IPRange - fields = [ - 'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role', - 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description') - - -# -# IP addresses -# - -class IPAddressSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') - family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) - address = IPAddressField() - vrf = NestedVRFSerializer(required=False, allow_null=True) - tenant = NestedTenantSerializer(required=False, allow_null=True) - status = ChoiceField(choices=IPAddressStatusChoices, required=False) - role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) - assigned_object_type = ContentTypeField( - queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS), - required=False, - allow_null=True - ) - assigned_object = serializers.SerializerMethodField(read_only=True) - nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) - nat_outside = NestedIPAddressSerializer(many=True, read_only=True) - - class Meta: - model = IPAddress - fields = [ - 'id', 'url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', - 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments', - 'tags', 'custom_fields', 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'family', 'address', 'description') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_assigned_object(self, obj): - if obj.assigned_object is None: - return None - serializer = get_serializer_for_model(obj.assigned_object, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.assigned_object, context=context).data - - -class AvailableIPSerializer(serializers.Serializer): - """ - Representation of an IP address which does not exist in the database. - """ - family = serializers.IntegerField(read_only=True) - address = serializers.CharField(read_only=True) - vrf = NestedVRFSerializer(read_only=True) - description = serializers.CharField(required=False) - - def to_representation(self, instance): - if self.context.get('vrf'): - vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data - else: - vrf = None - return { - 'family': self.context['parent'].family, - 'address': f"{instance}/{self.context['parent'].mask_length}", - 'vrf': vrf, - } - - -# -# Services -# - -class ServiceTemplateSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail') - protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) - - class Meta: - model = ServiceTemplate - fields = [ - 'id', 'url', 'display', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description') - - -class ServiceSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') - device = NestedDeviceSerializer(required=False, allow_null=True) - virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) - protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) - ipaddresses = SerializedPKRelatedField( - queryset=IPAddress.objects.all(), - serializer=NestedIPAddressSerializer, - required=False, - many=True - ) - - class Meta: - model = Service - fields = [ - 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', - 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - ] - brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/api/serializers_/__init__.py b/netbox/ipam/api/serializers_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/ipam/api/serializers_/asns.py b/netbox/ipam/api/serializers_/asns.py new file mode 100644 index 000000000..9a8ab5b00 --- /dev/null +++ b/netbox/ipam/api/serializers_/asns.py @@ -0,0 +1,78 @@ +from rest_framework import serializers + +from ipam.models import ASN, ASNRange, RIR +from netbox.api.fields import RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer + +__all__ = ( + 'ASNRangeSerializer', + 'ASNSerializer', + 'AvailableASNSerializer', + 'RIRSerializer', +) + + +class RIRSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') + + # Related object counts + aggregate_count = RelatedObjectCountField('aggregates') + + class Meta: + model = RIR + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'aggregate_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'aggregate_count') + + +class ASNRangeSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asnrange-detail') + rir = RIRSerializer(nested=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + asn_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ASNRange + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'asn_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class ASNSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') + rir = RIRSerializer(nested=True, required=False, allow_null=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + + # Related object counts + site_count = RelatedObjectCountField('sites') + provider_count = RelatedObjectCountField('providers') + + class Meta: + model = ASN + fields = [ + 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'site_count', 'provider_count', + ] + brief_fields = ('id', 'url', 'display', 'asn', 'description') + + +class AvailableASNSerializer(serializers.Serializer): + """ + Representation of an ASN which does not exist in the database. + """ + asn = serializers.IntegerField(read_only=True) + description = serializers.CharField(required=False) + + def to_representation(self, asn): + rir = RIRSerializer(self.context['range'].rir, nested=True, context={ + 'request': self.context['request'] + }).data + return { + 'rir': rir, + 'asn': asn, + } diff --git a/netbox/ipam/api/serializers_/fhrpgroups.py b/netbox/ipam/api/serializers_/fhrpgroups.py new file mode 100644 index 000000000..9bf1d4548 --- /dev/null +++ b/netbox/ipam/api/serializers_/fhrpgroups.py @@ -0,0 +1,52 @@ +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from ipam.models import FHRPGroup, FHRPGroupAssignment +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import NetBoxModelSerializer +from utilities.api import get_serializer_for_model +from .ip import IPAddressSerializer + +__all__ = ( + 'FHRPGroupAssignmentSerializer', + 'FHRPGroupSerializer', +) + + +class FHRPGroupSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail') + ip_addresses = IPAddressSerializer(nested=True, many=True, read_only=True) + + class Meta: + model = FHRPGroup + fields = [ + 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses', + ] + brief_fields = ('id', 'url', 'display', 'protocol', 'group_id', 'description') + + +class FHRPGroupAssignmentSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail') + group = FHRPGroupSerializer(nested=True) + interface_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + interface = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = FHRPGroupAssignment + fields = [ + 'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority') + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_interface(self, obj): + if obj.interface is None: + return None + serializer = get_serializer_for_model(obj.interface) + context = {'request': self.context['request']} + return serializer(obj.interface, nested=True, context=context).data diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py new file mode 100644 index 000000000..e5fa81314 --- /dev/null +++ b/netbox/ipam/api/serializers_/ip.py @@ -0,0 +1,198 @@ +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dcim.api.serializers_.sites import SiteSerializer +from ipam.choices import * +from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS +from ipam.models import Aggregate, IPAddress, IPRange, Prefix +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer +from utilities.api import get_serializer_for_model +from .asns import RIRSerializer +from .roles import RoleSerializer +from .vlans import VLANSerializer +from .vrfs import VRFSerializer +from ..field_serializers import IPAddressField, IPNetworkField +from ..nested_serializers import * + +__all__ = ( + 'AggregateSerializer', + 'AvailableIPSerializer', + 'AvailablePrefixSerializer', + 'IPAddressSerializer', + 'IPRangeSerializer', + 'PrefixLengthSerializer', + 'PrefixSerializer', +) + + +class AggregateSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') + family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) + rir = RIRSerializer(nested=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + prefix = IPNetworkField() + + class Meta: + model = Aggregate + fields = [ + 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description') + + +class PrefixSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') + family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) + site = SiteSerializer(nested=True, required=False, allow_null=True) + vrf = VRFSerializer(nested=True, required=False, allow_null=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + vlan = VLANSerializer(nested=True, required=False, allow_null=True) + status = ChoiceField(choices=PrefixStatusChoices, required=False) + role = RoleSerializer(nested=True, required=False, allow_null=True) + children = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(read_only=True) + prefix = IPNetworkField() + + class Meta: + model = Prefix + fields = [ + 'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', + 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', + '_depth', + ] + brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth') + + +class PrefixLengthSerializer(serializers.Serializer): + + prefix_length = serializers.IntegerField() + + def to_internal_value(self, data): + requested_prefix = data.get('prefix_length') + if requested_prefix is None: + raise serializers.ValidationError({ + 'prefix_length': 'this field can not be missing' + }) + if not isinstance(requested_prefix, int): + raise serializers.ValidationError({ + 'prefix_length': 'this field must be int type' + }) + + prefix = self.context.get('prefix') + if prefix.family == 4 and requested_prefix > 32: + raise serializers.ValidationError({ + 'prefix_length': 'Invalid prefix length ({}) for IPv4'.format(requested_prefix) + }) + elif prefix.family == 6 and requested_prefix > 128: + raise serializers.ValidationError({ + 'prefix_length': 'Invalid prefix length ({}) for IPv6'.format(requested_prefix) + }) + return data + + +class AvailablePrefixSerializer(serializers.Serializer): + """ + Representation of a prefix which does not exist in the database. + """ + family = serializers.IntegerField(read_only=True) + prefix = serializers.CharField(read_only=True) + vrf = VRFSerializer(nested=True, read_only=True) + + def to_representation(self, instance): + if self.context.get('vrf'): + vrf = VRFSerializer(self.context['vrf'], nested=True, context={'request': self.context['request']}).data + else: + vrf = None + return { + 'family': instance.version, + 'prefix': str(instance), + 'vrf': vrf, + } + + +# +# IP ranges +# + +class IPRangeSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:iprange-detail') + family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) + start_address = IPAddressField() + end_address = IPAddressField() + vrf = VRFSerializer(nested=True, required=False, allow_null=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + status = ChoiceField(choices=IPRangeStatusChoices, required=False) + role = RoleSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = IPRange + fields = [ + 'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description') + + +# +# IP addresses +# + +class IPAddressSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') + family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) + address = IPAddressField() + vrf = VRFSerializer(nested=True, required=False, allow_null=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + status = ChoiceField(choices=IPAddressStatusChoices, required=False) + role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS), + required=False, + allow_null=True + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) + nat_outside = NestedIPAddressSerializer(many=True, read_only=True) + + class Meta: + model = IPAddress + fields = [ + 'id', 'url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', + 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'family', 'address', 'description') + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_assigned_object(self, obj): + if obj.assigned_object is None: + return None + serializer = get_serializer_for_model(obj.assigned_object) + context = {'request': self.context['request']} + return serializer(obj.assigned_object, nested=True, context=context).data + + +class AvailableIPSerializer(serializers.Serializer): + """ + Representation of an IP address which does not exist in the database. + """ + family = serializers.IntegerField(read_only=True) + address = serializers.CharField(read_only=True) + vrf = VRFSerializer(nested=True, read_only=True) + description = serializers.CharField(required=False) + + def to_representation(self, instance): + if self.context.get('vrf'): + vrf = VRFSerializer(self.context['vrf'], nested=True, context={'request': self.context['request']}).data + else: + vrf = None + return { + 'family': self.context['parent'].family, + 'address': f"{instance}/{self.context['parent'].mask_length}", + 'vrf': vrf, + } diff --git a/netbox/ipam/api/serializers_/roles.py b/netbox/ipam/api/serializers_/roles.py new file mode 100644 index 000000000..8208b8074 --- /dev/null +++ b/netbox/ipam/api/serializers_/roles.py @@ -0,0 +1,25 @@ +from rest_framework import serializers + +from ipam.models import Role +from netbox.api.fields import RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer + +__all__ = ( + 'RoleSerializer', +) + + +class RoleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') + + # Related object counts + prefix_count = RelatedObjectCountField('prefixes') + vlan_count = RelatedObjectCountField('vlans') + + class Meta: + model = Role + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'prefix_count', 'vlan_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count') diff --git a/netbox/ipam/api/serializers_/services.py b/netbox/ipam/api/serializers_/services.py new file mode 100644 index 000000000..407739667 --- /dev/null +++ b/netbox/ipam/api/serializers_/services.py @@ -0,0 +1,49 @@ +from rest_framework import serializers + +from dcim.api.serializers_.devices import DeviceSerializer +from ipam.choices import * +from ipam.models import IPAddress, Service, ServiceTemplate +from netbox.api.fields import ChoiceField, SerializedPKRelatedField +from netbox.api.serializers import NetBoxModelSerializer +from virtualization.api.serializers_.virtualmachines import VirtualMachineSerializer +from .ip import IPAddressSerializer + +__all__ = ( + 'ServiceSerializer', + 'ServiceTemplateSerializer', +) + + +class ServiceTemplateSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail') + protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) + + class Meta: + model = ServiceTemplate + fields = [ + 'id', 'url', 'display', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description') + + +class ServiceSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') + device = DeviceSerializer(nested=True, required=False, allow_null=True) + virtual_machine = VirtualMachineSerializer(nested=True, required=False, allow_null=True) + protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) + ipaddresses = SerializedPKRelatedField( + queryset=IPAddress.objects.all(), + serializer=IPAddressSerializer, + nested=True, + required=False, + many=True + ) + + class Meta: + model = Service + fields = [ + 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py new file mode 100644 index 000000000..a400f949b --- /dev/null +++ b/netbox/ipam/api/serializers_/vlans.py @@ -0,0 +1,112 @@ +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dcim.api.serializers_.sites import SiteSerializer +from ipam.choices import * +from ipam.constants import VLANGROUP_SCOPE_TYPES +from ipam.models import VLAN, VLANGroup +from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer +from utilities.api import get_serializer_for_model +from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer +from .roles import RoleSerializer + +__all__ = ( + 'AvailableVLANSerializer', + 'CreateAvailableVLANSerializer', + 'VLANGroupSerializer', + 'VLANSerializer', +) + + +class VLANGroupSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') + scope_type = ContentTypeField( + queryset=ContentType.objects.filter( + model__in=VLANGROUP_SCOPE_TYPES + ), + allow_null=True, + required=False, + default=None + ) + scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) + scope = serializers.SerializerMethodField(read_only=True) + utilization = serializers.CharField(read_only=True) + + # Related object counts + vlan_count = RelatedObjectCountField('vlans') + + class Meta: + model = VLANGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid', + 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count') + validators = [] + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_scope(self, obj): + if obj.scope_id is None: + return None + serializer = get_serializer_for_model(obj.scope) + context = {'request': self.context['request']} + return serializer(obj.scope, nested=True, context=context).data + + +class VLANSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') + site = SiteSerializer(nested=True, required=False, allow_null=True) + group = VLANGroupSerializer(nested=True, required=False, allow_null=True, default=None) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + status = ChoiceField(choices=VLANStatusChoices, required=False) + role = RoleSerializer(nested=True, required=False, allow_null=True) + l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) + + # Related object counts + prefix_count = RelatedObjectCountField('prefixes') + + class Meta: + model = VLAN + fields = [ + 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', + 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', + ] + brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description') + + +class AvailableVLANSerializer(serializers.Serializer): + """ + Representation of a VLAN which does not exist in the database. + """ + vid = serializers.IntegerField(read_only=True) + group = VLANGroupSerializer(nested=True, read_only=True) + + def to_representation(self, instance): + return { + 'vid': instance, + 'group': VLANGroupSerializer( + self.context['group'], + nested=True, + context={'request': self.context['request']} + ).data, + } + + +class CreateAvailableVLANSerializer(NetBoxModelSerializer): + site = SiteSerializer(nested=True, required=False, allow_null=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + status = ChoiceField(choices=VLANStatusChoices, required=False) + role = RoleSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = VLAN + fields = [ + 'name', 'site', 'tenant', 'status', 'role', 'description', 'tags', 'custom_fields', + ] + + def validate(self, data): + # Bypass model validation since we don't have a VID yet + return data diff --git a/netbox/ipam/api/serializers_/vrfs.py b/netbox/ipam/api/serializers_/vrfs.py new file mode 100644 index 000000000..fdb5f98ab --- /dev/null +++ b/netbox/ipam/api/serializers_/vrfs.py @@ -0,0 +1,54 @@ +from rest_framework import serializers + +from ipam.models import RouteTarget, VRF +from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField +from netbox.api.serializers import NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer + +__all__ = ( + 'RouteTargetSerializer', + 'VRFSerializer', +) + + +class RouteTargetSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail') + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = RouteTarget + fields = [ + 'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class VRFSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + import_targets = SerializedPKRelatedField( + queryset=RouteTarget.objects.all(), + serializer=RouteTargetSerializer, + required=False, + many=True + ) + export_targets = SerializedPKRelatedField( + queryset=RouteTarget.objects.all(), + serializer=RouteTargetSerializer, + required=False, + many=True + ) + + # Related object counts + ipaddress_count = RelatedObjectCountField('ip_addresses') + prefix_count = RelatedObjectCountField('prefixes') + + class Meta: + model = VRF + fields = [ + 'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', + 'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', + 'prefix_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'rd', 'description', 'prefix_count') diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 404baf71b..d58f5bfc9 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -8,6 +8,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from netaddr.core import AddrFormatError +from circuits.models import Provider from dcim.models import Device, Interface, Region, Site, SiteGroup from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet @@ -75,7 +76,7 @@ class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = VRF - fields = ['id', 'name', 'rd', 'enforce_unique', 'description'] + fields = ('id', 'name', 'rd', 'enforce_unique', 'description') class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): @@ -101,6 +102,28 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='rd', label=_('Export VRF (RD)'), ) + importing_l2vpn_id = django_filters.ModelMultipleChoiceFilter( + field_name='importing_l2vpns', + queryset=L2VPN.objects.all(), + label=_('Importing L2VPN'), + ) + importing_l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='importing_l2vpns__identifier', + queryset=L2VPN.objects.all(), + to_field_name='identifier', + label=_('Importing L2VPN (identifier)'), + ) + exporting_l2vpn_id = django_filters.ModelMultipleChoiceFilter( + field_name='exporting_l2vpns', + queryset=L2VPN.objects.all(), + label=_('Exporting L2VPN'), + ) + exporting_l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='exporting_l2vpns__identifier', + queryset=L2VPN.objects.all(), + to_field_name='identifier', + label=_('Exporting L2VPN (identifier)'), + ) def search(self, queryset, name, value): if not value.strip(): @@ -112,14 +135,14 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = RouteTarget - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'description') class RIRFilterSet(OrganizationalModelFilterSet): class Meta: model = RIR - fields = ['id', 'name', 'slug', 'is_private', 'description'] + fields = ('id', 'name', 'slug', 'is_private', 'description') class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): @@ -144,7 +167,7 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = Aggregate - fields = ['id', 'date_added', 'description'] + fields = ('id', 'date_added', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -183,7 +206,7 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): class Meta: model = ASNRange - fields = ['id', 'name', 'start', 'end', 'description'] + fields = ('id', 'name', 'slug', 'start', 'end', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -214,10 +237,21 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): to_field_name='slug', label=_('Site (slug)'), ) + provider_id = django_filters.ModelMultipleChoiceFilter( + field_name='providers', + queryset=Provider.objects.all(), + label=_('Provider (ID)'), + ) + provider = django_filters.ModelMultipleChoiceFilter( + field_name='providers__slug', + queryset=Provider.objects.all(), + to_field_name='slug', + label=_('Provider (slug)'), + ) class Meta: model = ASN - fields = ['id', 'asn', 'description'] + fields = ('id', 'asn', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -234,7 +268,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): class Meta: model = Role - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description', 'weight') class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): @@ -359,7 +393,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = Prefix - fields = ['id', 'is_pool', 'mark_utilized', 'description'] + fields = ('id', 'is_pool', 'mark_utilized', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -475,7 +509,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet): class Meta: model = IPRange - fields = ['id', 'mark_utilized', 'description'] + fields = ('id', 'mark_utilized', 'size', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -628,10 +662,20 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): role = django_filters.MultipleChoiceFilter( choices=IPAddressRoleChoices ) + service_id = django_filters.ModelMultipleChoiceFilter( + field_name='services', + queryset=Service.objects.all(), + label=_('Service (ID)'), + ) + nat_inside_id = django_filters.ModelMultipleChoiceFilter( + field_name='nat_inside', + queryset=IPAddress.objects.all(), + label=_('NAT inside IP address (ID)'), + ) class Meta: model = IPAddress - fields = ['id', 'dns_name', 'description'] + fields = ('id', 'dns_name', 'description', 'assigned_object_type', 'assigned_object_id') def search(self, queryset, name, value): if not value.strip(): @@ -758,7 +802,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet): class Meta: model = FHRPGroup - fields = ['id', 'group_id', 'name', 'auth_key', 'description'] + fields = ('id', 'group_id', 'name', 'auth_key', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -819,7 +863,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): class Meta: model = FHRPGroupAssignment - fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority'] + fields = ('id', 'group_id', 'interface_type', 'interface_id', 'priority') def filter_device(self, queryset, name, value): devices = Device.objects.filter(**{f'{name}__in': value}) @@ -849,7 +893,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): region = django_filters.NumberFilter( method='filter_scope' ) - sitegroup = django_filters.NumberFilter( + site_group = django_filters.NumberFilter( method='filter_scope' ) site = django_filters.NumberFilter( @@ -861,16 +905,20 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): rack = django_filters.NumberFilter( method='filter_scope' ) - clustergroup = django_filters.NumberFilter( + cluster_group = django_filters.NumberFilter( method='filter_scope' ) cluster = django_filters.NumberFilter( method='filter_scope' ) + # TODO: Remove in v4.1 + sitegroup = site_group + clustergroup = cluster_group + class Meta: model = VLANGroup - fields = ['id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id'] + fields = ('id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id') def search(self, queryset, name, value): if not value.strip(): @@ -882,8 +930,9 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): return queryset.filter(qs_filter) def filter_scope(self, queryset, name, value): + model_name = name.replace('_', '') return queryset.filter( - scope_type=ContentType.objects.get(model=name), + scope_type=ContentType.objects.get(model=model_name), scope_id=value ) @@ -975,7 +1024,7 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = VLAN - fields = ['id', 'vid', 'name', 'description'] + fields = ('id', 'vid', 'name', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1008,7 +1057,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet): class Meta: model = ServiceTemplate - fields = ['id', 'name', 'protocol', 'description'] + fields = ('id', 'name', 'protocol', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1041,26 +1090,29 @@ class ServiceFilterSet(NetBoxModelFilterSet): to_field_name='name', label=_('Virtual machine (name)'), ) - ipaddress_id = django_filters.ModelMultipleChoiceFilter( + ip_address_id = django_filters.ModelMultipleChoiceFilter( field_name='ipaddresses', queryset=IPAddress.objects.all(), label=_('IP address (ID)'), ) - ipaddress = django_filters.ModelMultipleChoiceFilter( + ip_address = django_filters.ModelMultipleChoiceFilter( field_name='ipaddresses__address', queryset=IPAddress.objects.all(), to_field_name='address', label=_('IP address'), ) - port = NumericArrayFilter( field_name='ports', lookup_expr='contains' ) + # TODO: Remove in v4.1 + ipaddress = ip_address + ipaddress_id = ip_address_id + class Meta: model = Service - fields = ['id', 'name', 'protocol', 'description'] + fields = ('id', 'name', 'protocol', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 909de886f..cf2e4d46e 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -304,7 +304,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): 'placeholder': 'Prefix', } ), - label='Parent Prefix' + label=_('Parent Prefix') ) family = forms.ChoiceField( required=False, diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 689e18940..47087139a 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -373,20 +373,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): 'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.") ) - # Do not allow assigning a network ID or broadcast address to an interface. - if interface and (address := self.cleaned_data.get('address')): - if address.ip == address.network: - msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(ip=address.ip) - if address.version == 4 and address.prefixlen not in (31, 32): - raise ValidationError(msg) - if address.version == 6 and address.prefixlen not in (127, 128): - raise ValidationError(msg) - if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32): - msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format( - ip=address.ip - ) - raise ValidationError(msg) - def save(self, *args, **kwargs): ipaddress = super().save(*args, **kwargs) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index b4350f9f2..d19837fd1 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,6 +1,7 @@ import graphene from ipam import filtersets, models +from .mixins import IPAddressesMixin from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType @@ -71,7 +72,7 @@ class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType): filterset_class = filtersets.AggregateFilterSet -class FHRPGroupType(NetBoxObjectType): +class FHRPGroupType(NetBoxObjectType, IPAddressesMixin): class Meta: model = models.FHRPGroup diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 76fae2990..422c5ba37 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -8,7 +8,7 @@ from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from core.models import ContentType +from core.models import ObjectType from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -844,6 +844,25 @@ class IPAddress(PrimaryModel): 'address': _("Cannot create IP address with /0 mask.") }) + # Do not allow assigning a network ID or broadcast address to an interface. + if self.assigned_object: + if self.address.ip == self.address.network: + msg = _("{ip} is a network ID, which may not be assigned to an interface.").format( + ip=self.address.ip + ) + if self.address.version == 4 and self.address.prefixlen not in (31, 32): + raise ValidationError(msg) + if self.address.version == 6 and self.address.prefixlen not in (127, 128): + raise ValidationError(msg) + if ( + self.address.version == 4 and self.address.ip == self.address.broadcast and + self.address.prefixlen not in (31, 32) + ): + msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format( + ip=self.address.ip + ) + raise ValidationError(msg) + # Enforce unique IP space (if applicable) if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_ips = self.get_duplicates() @@ -861,7 +880,7 @@ class IPAddress(PrimaryModel): if self._original_assigned_object_id and self._original_assigned_object_type_id: parent = getattr(self.assigned_object, 'parent_object', None) - ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id) + ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id) original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) original_parent = getattr(original_assigned_object, 'parent_object', None) diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index bb4f50c21..3a46423a5 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from netaddr import IPNetwork +from circuits.models import Provider from dcim.choices import InterfaceTypeChoices from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup from ipam.choices import * @@ -10,6 +11,8 @@ from ipam.models import * from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from vpn.choices import L2VPNTypeChoices +from vpn.models import L2VPN class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -110,13 +113,6 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): ] RIR.objects.bulk_create(rirs) - sites = [ - Site(name='Site 1', slug='site-1'), - Site(name='Site 2', slug='site-2'), - Site(name='Site 3', slug='site-3') - ] - Site.objects.bulk_create(sites) - tenants = [ Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -136,6 +132,12 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): ) ASN.objects.bulk_create(asns) + sites = [ + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3') + ] + Site.objects.bulk_create(sites) asns[0].sites.set([sites[0]]) asns[1].sites.set([sites[1]]) asns[2].sites.set([sites[2]]) @@ -143,6 +145,16 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): asns[4].sites.set([sites[1]]) asns[5].sites.set([sites[2]]) + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), + ) + Provider.objects.bulk_create(providers) + providers[0].asns.add(asns[0]) + providers[1].asns.add(asns[1]) + providers[2].asns.add(asns[2]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -176,11 +188,24 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_provider(self): + providers = Provider.objects.all()[:2] + params = {'provider_id': [providers[0].pk, providers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VRF.objects.all() filterset = VRFFilterSet + def get_m2m_filter_name(self, field): + # Override filter names for import & export RouteTargets + if field.name == 'import_targets': + return 'import_target' + if field.name == 'export_targets': + return 'export_target' + return ChangeLoggedFilterSetTests.get_m2m_filter_name(field) + @classmethod def setUpTestData(cls): @@ -277,6 +302,18 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RouteTarget.objects.all() filterset = RouteTargetFilterSet + def get_m2m_filter_name(self, field): + # Override filter names for import & export VRFs and L2VPNs + if field.name == 'importing_vrfs': + return 'importing_vrf' + if field.name == 'exporting_vrfs': + return 'exporting_vrf' + if field.name == 'importing_l2vpns': + return 'importing_l2vpn' + if field.name == 'exporting_l2vpns': + return 'exporting_l2vpn' + return ChangeLoggedFilterSetTests.get_m2m_filter_name(field) + @classmethod def setUpTestData(cls): @@ -322,6 +359,17 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests): vrfs[1].import_targets.add(route_targets[4], route_targets[5]) vrfs[1].export_targets.add(route_targets[6], route_targets[7]) + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=100), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=200), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=300), + ) + L2VPN.objects.bulk_create(l2vpns) + l2vpns[0].import_targets.add(route_targets[0], route_targets[1]) + l2vpns[0].export_targets.add(route_targets[2], route_targets[3]) + l2vpns[1].import_targets.add(route_targets[4], route_targets[5]) + l2vpns[1].export_targets.add(route_targets[6], route_targets[7]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -344,6 +392,20 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'exporting_vrf': [vrfs[0].rd, vrfs[1].rd]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_importing_l2vpn(self): + l2vpns = L2VPN.objects.all()[:2] + params = {'importing_l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'importing_l2vpn': [l2vpns[0].identifier, l2vpns[1].identifier]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_exporting_l2vpn(self): + l2vpns = L2VPN.objects.all()[:2] + params = {'exporting_l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'exporting_l2vpn': [l2vpns[0].identifier, l2vpns[1].identifier]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_tenant(self): tenants = Tenant.objects.all()[:2] params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} @@ -922,6 +984,7 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests): class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = IPAddress.objects.all() filterset = IPAddressFilterSet + ignore_fields = ('fhrpgroup',) @classmethod def setUpTestData(cls): @@ -1092,6 +1155,16 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): ) IPAddress.objects.bulk_create(ipaddresses) + services = ( + Service(name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), + Service(name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), + Service(name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), + ) + Service.objects.bulk_create(services) + services[0].ipaddresses.add(ipaddresses[0]) + services[1].ipaddresses.add(ipaddresses[1]) + services[2].ipaddresses.add(ipaddresses[2]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -1231,6 +1304,11 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_service(self): + services = Service.objects.all()[:2] + params = {'service_id': [services[0].pk, services[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FHRPGroup.objects.all() @@ -1475,6 +1553,7 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VLAN.objects.all() filterset = VLANFilterSet + ignore_fields = ('interfaces_as_tagged', 'vminterfaces_as_tagged') @classmethod def setUpTestData(cls): @@ -1733,6 +1812,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ServiceTemplate.objects.all() filterset = ServiceTemplateFilterSet + ignore_fields = ('ports',) @classmethod def setUpTestData(cls): @@ -1797,6 +1877,7 @@ class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Service.objects.all() filterset = ServiceFilterSet + ignore_fields = ('ports',) @classmethod def setUpTestData(cls): @@ -1883,9 +1964,9 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'virtual_machine': [vms[0].name, vms[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_ipaddress(self): + def test_ip_address(self): ips = IPAddress.objects.all()[:2] - params = {'ipaddress_id': [ips[0].pk, ips[1].pk]} + params = {'ip_address_id': [ips[0].pk, ips[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]} + params = {'ip_address': [str(ips[0].address), str(ips[1].address)]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index 241dce0a0..08ffd0bc4 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -132,13 +132,15 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField): Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related objects in a ManyToManyField while still allowing a set of primary keys to be written. """ - def __init__(self, serializer, **kwargs): + def __init__(self, serializer, nested=False, **kwargs): self.serializer = serializer + self.nested = nested self.pk_field = kwargs.pop('pk_field', None) + super().__init__(**kwargs) def to_representation(self, value): - return self.serializer(value, context={'request': self.context['request']}).data + return self.serializer(value, nested=self.nested, context={'request': self.context['request']}).data @extend_schema_field(OpenApiTypes.INT64) diff --git a/netbox/netbox/api/serializers/base.py b/netbox/netbox/api/serializers/base.py index c715b2d26..4445f62da 100644 --- a/netbox/netbox/api/serializers/base.py +++ b/netbox/netbox/api/serializers/base.py @@ -1,8 +1,12 @@ -from django.db.models import ManyToManyField +from functools import cached_property + from rest_framework import serializers +from rest_framework.utils.serializer_helpers import BindingDict from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes +from utilities.api import get_related_object_by_attrs + __all__ = ( 'BaseModelSerializer', 'ValidatedModelSerializer', @@ -12,14 +16,48 @@ __all__ = ( class BaseModelSerializer(serializers.ModelSerializer): display = serializers.SerializerMethodField(read_only=True) - def __init__(self, *args, requested_fields=None, **kwargs): + def __init__(self, *args, nested=False, fields=None, **kwargs): + """ + Extends the base __init__() method to support dynamic fields. + + :param nested: Set to True if this serializer is being employed within a parent serializer + :param fields: An iterable of fields to include when rendering the serialized object, If nested is + True but no fields are specified, Meta.brief_fields will be used. + """ + self.nested = nested + self._requested_fields = fields + + # If this serializer is nested but no fields have been specified, + # default to using Meta.brief_fields (if set) + if nested and not fields: + self._requested_fields = getattr(self.Meta, 'brief_fields', None) + super().__init__(*args, **kwargs) - # If specific fields have been requested, omit the others - if requested_fields: - for field in list(self.fields.keys()): - if field not in requested_fields: - self.fields.pop(field) + def to_internal_value(self, data): + + # If initialized as a nested serializer, we should expect to receive the attrs or PK + # identifying a related object. + if self.nested: + queryset = self.Meta.model.objects.all() + return get_related_object_by_attrs(queryset, data) + + return super().to_internal_value(data) + + @cached_property + def fields(self): + """ + Override the fields property to check for requested fields. If defined, + return only the applicable fields. + """ + if not self._requested_fields: + return super().fields + + fields = BindingDict(self) + for key, value in self.get_fields().items(): + if key in self._requested_fields: + fields[key] = value + return fields @extend_schema_field(OpenApiTypes.STR) def get_display(self, obj): @@ -32,6 +70,11 @@ class ValidatedModelSerializer(BaseModelSerializer): validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144) """ def validate(self, data): + + # Skip validation if we're being used to represent a nested object + if self.nested: + return data + attrs = data.copy() # Remove custom field data (if any) prior to model validation diff --git a/netbox/netbox/api/serializers/features.py b/netbox/netbox/api/serializers/features.py index 1374ba526..3bd5c8a2d 100644 --- a/netbox/netbox/api/serializers/features.py +++ b/netbox/netbox/api/serializers/features.py @@ -1,9 +1,7 @@ -from django.contrib.contenttypes.models import ContentType from rest_framework import serializers from rest_framework.fields import CreateOnlyDefault from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues -from extras.models import CustomField from .nested import NestedTagSerializer __all__ = ( diff --git a/netbox/netbox/api/serializers/generic.py b/netbox/netbox/api/serializers/generic.py index 545ebb936..fb4fab8b0 100644 --- a/netbox/netbox/api/serializers/generic.py +++ b/netbox/netbox/api/serializers/generic.py @@ -3,7 +3,6 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from netbox.api.fields import ContentTypeField -from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model from utilities.utils import content_type_identifier @@ -40,6 +39,5 @@ class GenericObjectSerializer(serializers.Serializer): @extend_schema_field(serializers.JSONField(allow_null=True)) def get_object(self, obj): - serializer = get_serializer_for_model(obj, prefix=NESTED_SERIALIZER_PREFIX) - # context = {'request': self.context['request']} - return serializer(obj, context=self.context).data + serializer = get_serializer_for_model(obj) + return serializer(obj, nested=True, context=self.context).data diff --git a/netbox/netbox/api/serializers/nested.py b/netbox/netbox/api/serializers/nested.py index 027f3d11e..e43fd7428 100644 --- a/netbox/netbox/api/serializers/nested.py +++ b/netbox/netbox/api/serializers/nested.py @@ -1,10 +1,7 @@ -from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist -from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from rest_framework.exceptions import ValidationError from extras.models import Tag -from utilities.utils import dict_to_filter_params +from utilities.api import get_related_object_by_attrs from .base import BaseModelSerializer __all__ = ( @@ -20,43 +17,8 @@ class WritableNestedSerializer(BaseModelSerializer): subclassed to return a full representation of the related object on read. """ def to_internal_value(self, data): - - if data is None: - return None - - # Dictionary of related object attributes - if isinstance(data, dict): - params = dict_to_filter_params(data) - queryset = self.Meta.model.objects - try: - return queryset.get(**params) - except ObjectDoesNotExist: - raise ValidationError( - _("Related object not found using the provided attributes: {params}").format(params=params)) - except MultipleObjectsReturned: - raise ValidationError( - _("Multiple objects match the provided attributes: {params}").format(params=params) - ) - except FieldError as e: - raise ValidationError(e) - - # Integer PK of related object - try: - # Cast as integer in case a PK was mistakenly sent as a string - pk = int(data) - except (TypeError, ValueError): - raise ValidationError( - _( - "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an " - "unrecognized value: {value}" - ).format(value=data) - ) - - # Look up object by PK - try: - return self.Meta.model.objects.get(pk=pk) - except ObjectDoesNotExist: - raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk)) + queryset = self.Meta.model.objects.all() + return get_related_object_by_attrs(queryset, data) # Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 2f0431efa..d72507e8a 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -69,7 +69,7 @@ class BaseViewSet(GenericViewSet): # If specific fields have been requested, pass them to the serializer if self.requested_fields: - kwargs['requested_fields'] = self.requested_fields + kwargs['fields'] = self.requested_fields return super().get_serializer(*args, **kwargs) diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index 9d8286968..e07e2c78b 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -1,10 +1,10 @@ -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.http import Http404 from rest_framework import status from rest_framework.response import Response +from core.models import ObjectType from extras.models import ExportTemplate from netbox.api.serializers import BulkOperationSerializer @@ -26,9 +26,9 @@ class CustomFieldsMixin: context = super().get_serializer_context() if hasattr(self.queryset.model, 'custom_fields'): - content_type = ContentType.objects.get_for_model(self.queryset.model) + object_type = ObjectType.objects.get_for_model(self.queryset.model) context.update({ - 'custom_fields': content_type.custom_fields.all(), + 'custom_fields': object_type.custom_fields.all(), }) return context @@ -40,8 +40,8 @@ class ExportTemplatesMixin: """ def list(self, request, *args, **kwargs): if 'export' in request.GET: - content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model) - et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first() + object_type = ObjectType.objects.get_for_model(self.get_serializer_class().Meta.model) + et = ExportTemplate.objects.filter(object_types=object_type, name=request.GET['export']).first() if et is None: raise Http404 queryset = self.filter_queryset(self.get_queryset()) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 10555b373..c70c68bc0 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -4,13 +4,13 @@ from collections import defaultdict from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend -from django.contrib.auth.models import Group, AnonymousUser +from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ImproperlyConfigured from django.db.models import Q from django.utils.translation import gettext_lazy as _ from users.constants import CONSTRAINT_TOKEN_USER -from users.models import ObjectPermission +from users.models import Group, ObjectPermission from utilities.permissions import ( permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct, ) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 547e2079b..6a6928021 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,4 +1,5 @@ # Prefix for nested serializers +# TODO: Remove in v4.1 NESTED_SERIALIZER_PREFIX = 'Nested' # RQ queue names diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index ebb98d15f..7f07cfbfb 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -281,7 +281,7 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet): # Dynamically add a Filter for each CustomField applicable to the parent model custom_fields = CustomField.objects.filter( - content_types=ContentType.objects.get_for_model(self._meta.model) + object_types=ContentType.objects.get_for_model(self._meta.model) ).exclude( filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED ) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 7e1eaa80c..85064e79d 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext_lazy as _ +from core.models import ObjectType from extras.choices import * from extras.models import CustomField, Tag from utilities.forms import CSVModelForm @@ -88,7 +89,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): def _get_custom_fields(self, content_type): return CustomField.objects.filter( - content_types=content_type, + object_types=content_type, ui_editable=CustomFieldUIEditableChoices.YES ) @@ -129,9 +130,9 @@ class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form): self.fields['pk'].queryset = self.model.objects.all() # Restrict tag fields by model - ct = ContentType.objects.get_for_model(self.model) - self.fields['add_tags'].widget.add_query_param('for_object_type_id', ct.pk) - self.fields['remove_tags'].widget.add_query_param('for_object_type_id', ct.pk) + object_type = ObjectType.objects.get_for_model(self.model) + self.fields['add_tags'].widget.add_query_param('for_object_type_id', object_type.pk) + self.fields['remove_tags'].widget.add_query_param('for_object_type_id', object_type.pk) self._extend_nullable_fields() @@ -169,9 +170,9 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form) super().__init__(*args, **kwargs) # Limit saved filters to those applicable to the form's model - content_type = ContentType.objects.get_for_model(self.model) + object_type = ObjectType.objects.get_for_model(self.model) self.fields['filter_id'].widget.add_query_params({ - 'content_type_id': content_type.pk, + 'object_types_id': object_type.pk, }) def _get_custom_fields(self, content_type): diff --git a/netbox/netbox/forms/mixins.py b/netbox/netbox/forms/mixins.py index 815f1f6fa..6b1f31265 100644 --- a/netbox/netbox/forms/mixins.py +++ b/netbox/netbox/forms/mixins.py @@ -1,7 +1,7 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ +from core.models import ObjectType from extras.choices import * from extras.models import * from utilities.forms.fields import DynamicModelMultipleChoiceField @@ -32,16 +32,16 @@ class CustomFieldsMixin: def _get_content_type(self): """ - Return the ContentType of the form's model. + Return the ObjectType of the form's model. """ if not getattr(self, 'model', None): raise NotImplementedError(_("{class_name} must specify a model class.").format( class_name=self.__class__.__name__ )) - return ContentType.objects.get_for_model(self.model) + return ObjectType.objects.get_for_model(self.model) def _get_custom_fields(self, content_type): - return CustomField.objects.filter(content_types=content_type).exclude( + return CustomField.objects.filter(object_types=content_type).exclude( ui_editable=CustomFieldUIEditableChoices.HIDDEN ) @@ -85,6 +85,6 @@ class TagsMixin(forms.Form): super().__init__(*args, **kwargs) # Limit tags to those applicable to the object type - content_type = ContentType.objects.get_for_model(self._meta.model) - if content_type and hasattr(self.fields['tags'].widget, 'add_query_param'): - self.fields['tags'].widget.add_query_param('for_object_type_id', content_type.pk) + object_type = ObjectType.objects.get_for_model(self._meta.model) + if object_type and hasattr(self.fields['tags'].widget, 'add_query_param'): + self.fields['tags'].widget.add_query_param('for_object_type_id', object_type.pk) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 10847742b..f131f07cf 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -1,5 +1,6 @@ import graphene +from core.models import ObjectType as ObjectType_ from django.contrib.contenttypes.models import ContentType from extras.graphql.mixins import ( ChangelogMixin, @@ -11,7 +12,9 @@ from graphene_django import DjangoObjectType __all__ = ( 'BaseObjectType', + 'ContentTypeType', 'ObjectType', + 'ObjectTypeType', 'OrganizationalObjectType', 'NetBoxObjectType', ) @@ -90,3 +93,10 @@ class ContentTypeType(DjangoObjectType): class Meta: model = ContentType fields = ('id', 'app_label', 'model') + + +class ObjectTypeType(DjangoObjectType): + + class Meta: + model = ObjectType_ + fields = ('id', 'app_label', 'model') diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index dbd008354..bff9ee59f 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _ from taggit.managers import TaggableManager from core.choices import JobStatusChoices -from core.models import ContentType +from core.models import ObjectType from extras.choices import * from extras.utils import is_taggable from netbox.config import get_config @@ -329,7 +329,9 @@ class ImageAttachmentsMixin(models.Model): Enables the assignments of ImageAttachments. """ images = GenericRelation( - to='extras.ImageAttachment' + to='extras.ImageAttachment', + content_type_field='object_type', + object_id_field='object_id' ) class Meta: @@ -341,7 +343,9 @@ class ContactsMixin(models.Model): Enables the assignments of Contacts (via ContactAssignment). """ contacts = GenericRelation( - to='tenancy.ContactAssignment' + to='tenancy.ContactAssignment', + content_type_field='object_type', + object_id_field='object_id' ) class Meta: @@ -490,17 +494,17 @@ class SyncedDataMixin(models.Model): ret = super().save(*args, **kwargs) # Create/delete AutoSyncRecord as needed - content_type = ContentType.objects.get_for_model(self) + object_type = ObjectType.objects.get_for_model(self) if self.auto_sync_enabled: AutoSyncRecord.objects.update_or_create( - object_type=content_type, + object_type=object_type, object_id=self.pk, defaults={'datafile': self.data_file} ) else: AutoSyncRecord.objects.filter( datafile=self.data_file, - object_type=content_type, + object_type=object_type, object_id=self.pk ).delete() @@ -510,10 +514,10 @@ class SyncedDataMixin(models.Model): from core.models import AutoSyncRecord # Delete AutoSyncRecord - content_type = ContentType.objects.get_for_model(self) + object_type = ObjectType.objects.get_for_model(self) AutoSyncRecord.objects.filter( datafile=self.data_file, - object_type=content_type, + object_type=object_type, object_id=self.pk ).delete() diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 2dba76e72..621bd4f5d 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -392,19 +392,19 @@ ADMIN_MENU = Menu( ), # Proxy model for auth.Group MenuItem( - link=f'users:netboxgroup_list', + link=f'users:group_list', link_text=_('Groups'), permissions=[f'auth.view_group'], staff_only=True, buttons=( MenuItemButton( - link=f'users:netboxgroup_add', + link=f'users:group_add', title='Add', icon_class='mdi mdi-plus-thick', permissions=[f'auth.add_group'] ), MenuItemButton( - link=f'users:netboxgroup_import', + link=f'users:group_import', title='Import', icon_class='mdi mdi-upload', permissions=[f'auth.add_group'] diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index 590188f21..76898be13 100644 --- a/netbox/netbox/search/__init__.py +++ b/netbox/netbox/search/__init__.py @@ -1,6 +1,9 @@ from collections import namedtuple +from decimal import Decimal +from django.core.exceptions import FieldDoesNotExist from django.db import models +from netaddr import IPAddress, IPNetwork from ipam.fields import IPAddressField, IPNetworkField from netbox.registry import registry @@ -56,6 +59,24 @@ class SearchIndex: return FieldTypes.INTEGER return FieldTypes.STRING + @staticmethod + def get_attr_type(instance, field_name): + """ + Return the data type of the specified object attribute. + """ + value = getattr(instance, field_name) + if type(value) is str: + return FieldTypes.STRING + if type(value) is int: + return FieldTypes.INTEGER + if type(value) in (float, Decimal): + return FieldTypes.FLOAT + if type(value) is IPNetwork: + return FieldTypes.CIDR + if type(value) is IPAddress: + return FieldTypes.INET + return FieldTypes.STRING + @staticmethod def get_field_value(instance, field_name): """ @@ -82,7 +103,11 @@ class SearchIndex: # Capture built-in fields for name, weight in cls.fields: - type_ = cls.get_field_type(instance, name) + try: + type_ = cls.get_field_type(instance, name) + except FieldDoesNotExist: + # Not a concrete field; handle as an object attribute + type_ = cls.get_attr_type(instance, name) value = cls.get_field_value(instance, name) if type_ and value: values.append( diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index 1fb23a37c..a9e867b9f 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -11,6 +11,7 @@ from django.utils.module_loading import import_string import netaddr from netaddr.core import AddrFormatError +from core.models import ObjectType from extras.models import CachedValue, CustomField from netbox.registry import registry from utilities.querysets import RestrictedPrefetch @@ -130,11 +131,11 @@ class CachedValueSearchBackend(SearchBackend): ) )[:MAX_RESULTS] - # Gather all ContentTypes present in the search results (used for prefetching related + # Gather all ObjectTypes present in the search results (used for prefetching related # objects). This must be done before generating the final results list, which returns # a RawQuerySet. - content_type_ids = set(queryset.values_list('object_type', flat=True)) - content_types = ContentType.objects.filter(pk__in=content_type_ids) + object_type_ids = set(queryset.values_list('object_type', flat=True)) + object_types = ObjectType.objects.filter(pk__in=object_type_ids) # Construct a Prefetch to pre-fetch only those related objects for which the # user has permission to view. @@ -151,11 +152,11 @@ class CachedValueSearchBackend(SearchBackend): params ) - # Iterate through each ContentType represented in the search results and prefetch any + # Iterate through each ObjectType represented in the search results and prefetch any # related objects necessary to render the prescribed display attributes (display_attrs). - for ct in content_types: - model = ct.model_class() - indexer = registry['search'].get(content_type_identifier(ct)) + for object_type in object_types: + model = object_type.model_class() + indexer = registry['search'].get(content_type_identifier(object_type)) if not (display_attrs := getattr(indexer, 'display_attrs', None)): continue @@ -169,7 +170,7 @@ class CachedValueSearchBackend(SearchBackend): # Compile a list of all CachedValues referencing this object type, and prefetch # any related objects if prefetch_fields: - objects = [r for r in results if r.object_type == ct] + objects = [r for r in results if r.object_type == object_type] prefetch_related_objects(objects, *prefetch_fields) # Omit any results pertaining to an object the user does not have permission to view @@ -182,7 +183,7 @@ class CachedValueSearchBackend(SearchBackend): return ret def cache(self, instances, indexer=None, remove_existing=True): - content_type = None + object_type = None custom_fields = None # Convert a single instance to an iterable @@ -204,8 +205,8 @@ class CachedValueSearchBackend(SearchBackend): break # Prefetch any associated custom fields - content_type = ContentType.objects.get_for_model(indexer.model) - custom_fields = CustomField.objects.filter(content_types=content_type).exclude(search_weight=0) + object_type = ObjectType.objects.get_for_model(indexer.model) + custom_fields = CustomField.objects.filter(object_types=object_type).exclude(search_weight=0) # Wipe out any previously cached values for the object if remove_existing: @@ -215,7 +216,7 @@ class CachedValueSearchBackend(SearchBackend): for field in indexer.to_cache(instance, custom_fields=custom_fields): buffer.append( CachedValue( - object_type=content_type, + object_type=object_type, object_id=instance.pk, field=field.name, type=field.type, diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 495e56991..31502f6c5 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -3,7 +3,6 @@ from copy import deepcopy import django_tables2 as tables from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist from django.db.models.fields.related import RelatedField from django.urls import reverse @@ -12,6 +11,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django_tables2.data import TableQuerysetData +from core.models import ObjectType from extras.choices import * from extras.models import CustomField, CustomLink from netbox.registry import registry @@ -201,14 +201,14 @@ class NetBoxTable(BaseTable): ]) # Add custom field & custom link columns - content_type = ContentType.objects.get_for_model(self._meta.model) + object_type = ObjectType.objects.get_for_model(self._meta.model) custom_fields = CustomField.objects.filter( - content_types=content_type + object_types=object_type ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN) extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) - custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True) + custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True) extra_columns.extend([ (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links ]) @@ -263,9 +263,11 @@ class SearchTable(tables.Table): super().__init__(data, **kwargs) def render_field(self, value, record): - if hasattr(record.object, value): - return title(record.object._meta.get_field(value).verbose_name) - return value + try: + model_field = record.object._meta.get_field(value) + return title(model_field.verbose_name) + except FieldDoesNotExist: + return value def render_value(self, value): if not self.highlight: diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 1804087d1..6e049dcaf 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -2,17 +2,16 @@ import datetime from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group -from django.contrib.contenttypes.models import ContentType from django.test import Client from django.test.utils import override_settings from django.urls import reverse from netaddr import IPNetwork from rest_framework.test import APIClient +from core.models import ObjectType from dcim.models import Site from ipam.models import Prefix -from users.models import ObjectPermission, Token +from users.models import Group, ObjectPermission, Token from utilities.testing import TestCase from utilities.testing.api import APITestCase @@ -453,7 +452,7 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix)) # Retrieve permitted object url = reverse('ipam-api:prefix-detail', @@ -483,7 +482,7 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix)) # Retrieve all objects. Only permitted objects should be returned. response = self.client.get(url, **self.header) @@ -511,7 +510,7 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix)) # Attempt to create a non-permitted object response = self.client.post(url, data, format='json', **self.header) @@ -542,7 +541,7 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix)) # Attempt to edit a non-permitted object data = {'site': self.sites[0].pk} @@ -582,7 +581,7 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix)) # Attempt to delete a non-permitted object url = reverse('ipam-api:prefix-detail', diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py index bd07886e8..b0b21a07d 100644 --- a/netbox/netbox/tests/test_import.py +++ b/netbox/netbox/tests/test_import.py @@ -1,6 +1,6 @@ -from django.contrib.contenttypes.models import ContentType from django.test import override_settings +from core.models import ObjectType from dcim.models import * from users.models import ObjectPermission from utilities.choices import CSVDelimiterChoices, ImportFormatChoices @@ -67,7 +67,7 @@ class CSVImportTestCase(ModelViewTestCase): obj_perm = ObjectPermission(name='Test permission', actions=['add']) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('import')), 200) @@ -108,7 +108,7 @@ class CSVImportTestCase(ModelViewTestCase): obj_perm = ObjectPermission(name='Test permission', actions=['add']) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('import')), 200) diff --git a/netbox/netbox/tests/test_staging.py b/netbox/netbox/tests/test_staging.py index ed3a69f10..0a73b2987 100644 --- a/netbox/netbox/tests/test_staging.py +++ b/netbox/netbox/tests/test_staging.py @@ -1,9 +1,11 @@ +from django.db.models.signals import post_save from django.test import TransactionTestCase from circuits.models import Provider, Circuit, CircuitType from extras.choices import ChangeActionChoices from extras.models import Branch, StagedChange, Tag from ipam.models import ASN, RIR +from netbox.search.backends import search_backend from netbox.staging import checkout from utilities.testing import create_tags @@ -11,6 +13,10 @@ from utilities.testing import create_tags class StagingTestCase(TransactionTestCase): def setUp(self): + # Disconnect search backend to avoid issues with cached ObjectTypes being deleted + # from the database upon transaction rollback + post_save.disconnect(search_backend.caching_handler) + create_tags('Alpha', 'Bravo', 'Charlie') rir = RIR.objects.create(name='RIR 1', slug='rir-1') diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index d5dee0aae..3715fcb40 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -4,7 +4,6 @@ from copy import deepcopy from django.contrib import messages from django.contrib.contenttypes.fields import GenericRel -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError, RestrictedError @@ -17,6 +16,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from django_tables2.export import TableExport +from core.models import ObjectType from extras.models import ExportTemplate from extras.signals import clear_events from utilities.error_handlers import handle_protectederror @@ -125,7 +125,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): request: The current request """ model = self.queryset.model - content_type = ContentType.objects.get_for_model(model) + object_type = ObjectType.objects.get_for_model(model) if self.filterset: self.queryset = self.filterset(request.GET, self.queryset, request=request).qs @@ -144,7 +144,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): # Render an ExportTemplate elif request.GET['export']: - template = get_object_or_404(ExportTemplate, content_types=content_type, name=request.GET['export']) + template = get_object_or_404(ExportTemplate, object_types=object_type, name=request.GET['export']) return self.export_template(template, request) # Check for YAML export support on the model diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 5209b38eb..501149f58 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 44ab3554c..caa084612 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index eea47645e..b4518cb83 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/img/tint_20.png b/netbox/project-static/img/tint_20.png deleted file mode 100644 index a03a1f9ac..000000000 Binary files a/netbox/project-static/img/tint_20.png and /dev/null differ diff --git a/netbox/project-static/src/objectSelector.ts b/netbox/project-static/src/objectSelector.ts index 9de6c1750..633f5038a 100644 --- a/netbox/project-static/src/objectSelector.ts +++ b/netbox/project-static/src/objectSelector.ts @@ -18,11 +18,12 @@ function handleSelection(link: HTMLAnchorElement): void { const value = link.getAttribute('data-value'); //@ts-ignore - target.slim.setData([ - {text: label, value: value} - ]); - const change = new Event('change'); - target.dispatchEvent(change); + target.tomselect.addOption({ + id: value, + display: label, + }); + //@ts-ignore + target.tomselect.addItem(value); } diff --git a/netbox/project-static/src/select/dynamic.ts b/netbox/project-static/src/select/dynamic.ts index 710b665c2..10ce955c2 100644 --- a/netbox/project-static/src/select/dynamic.ts +++ b/netbox/project-static/src/select/dynamic.ts @@ -42,7 +42,7 @@ function renderItem(data: TomOption, escape: typeof escape_html) { // Initialize elements with statically-defined options export function initStaticSelects(): void { for (const select of getElements( - 'select:not(.tomselected):not(.api-select):not(.color-select)', + 'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)', )) { new TomSelect(select, { ...config, @@ -24,7 +24,7 @@ export function initColorSelects(): void { )}"> ${escape(item.text)}`; } - for (const select of getElements('select:not(.tomselected).color-select')) { + for (const select of getElements('select.color-select:not(.tomselected)')) { new TomSelect(select, { ...config, maxOptions: undefined, diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts index e1ada2e19..3aa8b6676 100644 --- a/netbox/project-static/src/util.ts +++ b/netbox/project-static/src/util.ts @@ -244,29 +244,6 @@ export function getSelectedOptions( return selected; } -/** - * Get data that can only be accessed via Django context, and is thus already rendered in the HTML - * template. - * - * @see Templates requiring Django context data have a `{% block data %}` block. - * - * @param key Property name, which must exist on the HTML element. If not already prefixed with - * `data-`, `data-` will be prepended to the property. - * @returns Value if it exists, `null` if not. - */ -export function getNetboxData(key: string): string | null { - if (!key.startsWith('data-')) { - key = `data-${key}`; - } - for (const element of getElements('body > div#netbox-data > *')) { - const value = element.getAttribute(key); - if (isTruthy(value)) { - return value; - } - } - return null; -} - /** * Toggle visibility of an element. */ diff --git a/netbox/project-static/styles/custom/_markdown.scss b/netbox/project-static/styles/custom/_markdown.scss index 08de23581..cb4527f37 100644 --- a/netbox/project-static/styles/custom/_markdown.scss +++ b/netbox/project-static/styles/custom/_markdown.scss @@ -28,6 +28,13 @@ } +// Remove the bottom margin of

elements inside a table cell +td > .rendered-markdown { + p:last-of-type { + margin-bottom: 0; + } +} + // Markdown preview .markdown-widget { .preview { diff --git a/netbox/project-static/styles/custom/_misc.scss b/netbox/project-static/styles/custom/_misc.scss index ebf66d547..9779bf583 100644 --- a/netbox/project-static/styles/custom/_misc.scss +++ b/netbox/project-static/styles/custom/_misc.scss @@ -2,7 +2,7 @@ // Color labels span.color-label { - display: block; + display: inline-block; width: 5rem; height: 1rem; padding: $badge-padding-y $badge-padding-x; diff --git a/netbox/project-static/styles/overrides/_tabler.scss b/netbox/project-static/styles/overrides/_tabler.scss index a5ae3c647..f855daf0c 100644 --- a/netbox/project-static/styles/overrides/_tabler.scss +++ b/netbox/project-static/styles/overrides/_tabler.scss @@ -9,6 +9,10 @@ pre { // Tabler sets display: flex display: inline-block; } +.btn-sm { + // $border-radius-sm (2px) is too small + border-radius: $border-radius; +} // Tabs .nav-tabs { diff --git a/netbox/project-static/styles/transitional/_tables.scss b/netbox/project-static/styles/transitional/_tables.scss index 6ac17c59c..0af11f9cd 100644 --- a/netbox/project-static/styles/transitional/_tables.scss +++ b/netbox/project-static/styles/transitional/_tables.scss @@ -23,7 +23,6 @@ table.attr-table { // Restyle row header th { - color: $gray-700; font-weight: normal; width: min-content; } diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 2ae1ebb9b..d2856ad15 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -71,10 +71,5 @@ {# User messages #} {% include 'inc/messages.html' %} - {# Data container #} -

- {% block data %}{% endblock %} -
- diff --git a/netbox/templates/core/job.html b/netbox/templates/core/job.html index a2170db20..0851fa114 100644 --- a/netbox/templates/core/job.html +++ b/netbox/templates/core/job.html @@ -56,7 +56,7 @@ {{ object.scheduled|annotated_date|placeholder }} {% if object.interval %} - ({% blocktrans with interval=object.interval %}every {{ interval }} seconds{% endblocktrans %}) + ({% blocktrans with interval=object.interval %}every {{ interval }} minutes{% endblocktrans %}) {% endif %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 97be5e839..b28ee6e31 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -163,7 +163,7 @@
- diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 752fe6913..e50380ba0 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -11,13 +11,11 @@ {% trans "View List" %} -
- -
+
{% trans "Front" %} {% trans "Rear" %} diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index ca6988152..1fec35417 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -17,7 +17,9 @@ Type {{ object.get_type_display }} - {% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %} + {% if object.related_object_type %} + ({{ object.related_object_type.model|bettertitle }}) + {% endif %} @@ -89,7 +91,7 @@
{% trans "Object Types" %}
- {% for ct in object.content_types.all %} + {% for ct in object.object_types.all %} diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html index 492408396..0b9b068da 100644 --- a/netbox/templates/extras/customlink.html +++ b/netbox/templates/extras/customlink.html @@ -38,7 +38,7 @@
{% trans "Assigned Models" %}
{{ ct }}
- {% for ct in object.content_types.all %} + {% for ct in object.object_types.all %} diff --git a/netbox/templates/extras/eventrule.html b/netbox/templates/extras/eventrule.html index d3c483819..844fbf9c6 100644 --- a/netbox/templates/extras/eventrule.html +++ b/netbox/templates/extras/eventrule.html @@ -26,9 +26,9 @@
{% trans "Object Types" %}
{{ ct }}
- {% for ct in object.content_types.all %} + {% for object_type in object.object_types.all %} - + {% endfor %}
{{ ct }}{{ object_type }}
diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index 0648a8191..8d14e3ffb 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -5,11 +5,6 @@ {% block title %}{{ object.name }}{% endblock %} -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} - {% block content %}
@@ -70,9 +65,9 @@
{% trans "Assigned Models" %}
- {% for ct in object.content_types.all %} + {% for object_type in object.object_types.all %} - + {% endfor %}
{{ ct }}{{ object_type }}
diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index ed5dd9cbd..e532e07e1 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -3,124 +3,63 @@ {% load log_levels %} {% load i18n %} -

- {% if job.started %} - {% trans "Started" %}: {{ job.started|annotated_date }} - {% elif job.scheduled %} - {% trans "Scheduled for" %}: {{ job.scheduled|annotated_date }} ({{ job.scheduled|naturaltime }}) - {% else %} - {% trans "Created" %}: {{ job.created|annotated_date }} - {% endif %} +

+

+ {% if job.started %} + {% trans "Started" %}: {{ job.started|annotated_date }} + {% elif job.scheduled %} + {% trans "Scheduled for" %}: {{ job.scheduled|annotated_date }} ({{ job.scheduled|naturaltime }}) + {% else %} + {% trans "Created" %}: {{ job.created|annotated_date }} + {% endif %} + {% if job.completed %} + {% trans "Duration" %}: {{ job.duration }} + {% endif %} + {% badge job.get_status_display job.get_status_color %} +

{% if job.completed %} - {% trans "Duration" %}: {{ job.duration }} - {% endif %} - {% badge job.get_status_display job.get_status_color %} -

-{% if job.completed %} - - {# Script log. Legacy reports will not have this. #} - {% if 'log' in job.data %} -
-
{% trans "Log" %}
- {% if job.data.log %} - - - - - - - - {% for log in job.data.log %} + {% if tests %} + {# Summary of test methods #} +
+
{% trans "Test Summary" %}
+
{% trans "Line" %}{% trans "Time" %}{% trans "Level" %}{% trans "Message" %}
+ {% for test, data in tests.items %} - - - - + + {% endfor %}
{{ forloop.counter }}{{ log.time|placeholder }}{% log_level log.status %}{{ log.message|markdown }}{{ test }} + {{ data.success }} + {{ data.info }} + {{ data.warning }} + {{ data.failure }} +
- {% else %} -
{% trans "None" %}
- {% endif %} -
- {% endif %} +
+ {% endif %} - {# Script output. Legacy reports will not have this. #} - {% if 'output' in job.data %} -
-
{% trans "Output" %}
- {% if job.data.output %} -
{{ job.data.output }}
- {% else %} -
{% trans "None" %}
- {% endif %} -
- {% endif %} - - {# Test method logs (for legacy Reports) #} - {% if tests %} - - {# Summary of test methods #} + {% if table %}
-
{% trans "Test Summary" %}
- - {% for test, data in tests.items %} - - - - - {% endfor %} -
{{ test }} - {{ data.success }} - {{ data.info }} - {{ data.warning }} - {{ data.failure }} -
+
+
{% trans "Log" %}
+ {% include 'htmx/table.html' %} +
+ {% endif %} - {# Detailed results for individual tests #} -
-
{% trans "Test Details" %}
- - - - - - - - - - - {% for test, data in tests.items %} - - - - {% for time, level, obj, url, message in data.log %} - - - - - - - {% endfor %} - {% endfor %} - -
{% trans "Time" %}{% trans "Level" %}{% trans "Object" %}{% trans "Message" %}
- {{ test }} -
{{ time }} - - - {% if obj and url %} - {{ obj }} - {% elif obj %} - {{ obj }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {{ message|markdown }}
-
+ {# Script output. Legacy reports will not have this. #} + {% if 'output' in job.data %} +
+
{% trans "Output" %}
+ {% if job.data.output %} +
{{ job.data.output }}
+ {% else %} +
{% trans "None" %}
+ {% endif %} +
+ {% endif %} + {% elif job.started %} + {% include 'extras/inc/result_pending.html' %} {% endif %} -{% elif job.started %} - {% include 'extras/inc/result_pending.html' %} -{% endif %} +
diff --git a/netbox/templates/extras/savedfilter.html b/netbox/templates/extras/savedfilter.html index 840852c7f..9b10f1375 100644 --- a/netbox/templates/extras/savedfilter.html +++ b/netbox/templates/extras/savedfilter.html @@ -38,9 +38,9 @@
{% trans "Assigned Models" %}
- {% for ct in object.content_types.all %} + {% for object_type in object.object_types.all %} - + {% endfor %}
{{ ct }}{{ object_type }}
diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index 8f6d817c7..030e73903 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -32,28 +32,74 @@ {% block tabs %} {% endblock %} {% block content %} -
-
-
- {% include 'extras/htmx/script_result.html' %} + {# Object list tab #} +
+ + {# Object table controls #} +
+
+ {% if request.user.is_authenticated %} +
+ +
+ {% endif %} +
+ +
+ {% csrf_token %} + {# "Select all" form #} + {% if table.paginator.num_pages > 1 %} +
+
+
+
+ + +
+
+
+
+ {% endif %} + +
+ {% csrf_token %} + + + {# Objects table #} +
+ {% include 'extras/htmx/script_result.html' %} +
+ {# /Objects table #} + +
+
-
-
-

{{ script.filename }}

-
{{ script.source }}
-
+ {# /Object list tab #} + + {# Filters tab #} + {% if filter_form %} +
+ {% include 'inc/filter_list.html' %} +
+ {% endif %} + {# /Filters tab #} + {% endblock content %} {% block modals %} - {% include 'inc/htmx_modal.html' %} + {% table_config_form table table_name="ObjectTable" %} {% endblock modals %} diff --git a/netbox/templates/inc/panels/image_attachments.html b/netbox/templates/inc/panels/image_attachments.html index a5f2ac18f..c3c7cf7e3 100644 --- a/netbox/templates/inc/panels/image_attachments.html +++ b/netbox/templates/inc/panels/image_attachments.html @@ -6,7 +6,7 @@ {% trans "Images" %} {% if perms.extras.add_imageattachment %} diff --git a/netbox/templates/inc/toast.html b/netbox/templates/inc/toast.html index 85eff2d7a..0cf04b93b 100644 --- a/netbox/templates/inc/toast.html +++ b/netbox/templates/inc/toast.html @@ -1,6 +1,6 @@ {% load helpers %} -