Merge branch 'feature' into 14871-ui-cleanup

This commit is contained in:
Jeremy Stretch 2024-03-04 14:20:34 -05:00
commit 61377fa523
194 changed files with 11128 additions and 5892 deletions

View File

@ -13,7 +13,9 @@ body:
- type: dropdown - type: dropdown
attributes: attributes:
label: Deployment Type label: Deployment Type
description: How are you running NetBox? description: >
How are you running NetBox? (For issues with the Docker image, please go to the
[netbox-docker](https://github.com/netbox-community/netbox-docker) repo.)
options: options:
- Self-hosted - Self-hosted
- NetBox Cloud - NetBox Cloud
@ -23,7 +25,7 @@ body:
attributes: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.7.2 placeholder: v3.7.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.7.2 placeholder: v3.7.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -5,7 +5,7 @@
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a> <a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a> <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a> <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-6-blue" alt="Languages supported" /></a> <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-7-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a> <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
<p></p> <p></p>
</div> </div>

View File

@ -105,7 +105,7 @@ mkdocs-material
mkdocstrings[python-legacy] mkdocstrings[python-legacy]
# Library for manipulating IP prefixes and addresses # Library for manipulating IP prefixes and addresses
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG # https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
netaddr netaddr
# Python bindings to the ammonia HTML sanitization library. # Python bindings to the ammonia HTML sanitization library.

View File

@ -67,7 +67,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
Default: `|` (Pipe) Default: `|` (Pipe)
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) The Separator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
--- ---

View File

@ -304,6 +304,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode
* `model` - The model class * `model` - The model class
* `query_params` - A dictionary of query parameters to use when retrieving available options (optional) * `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
* `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below)
* `null_option` - A label representing a "null" or empty choice (optional) * `null_option` - A label representing a "null" or empty choice (optional)
To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status: To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
@ -331,6 +332,22 @@ site = ObjectVar(
) )
``` ```
#### Context Variables
Custom context variables can be passed to override the default attribute names or to display additional information, such as a parent object.
| Name | Default | Description |
|---------------|-----------------|------------------------------------------------------------------------------|
| `value` | `"id"` | The attribute which contains the option's value |
| `label` | `"display"` | The attribute used as the option's human-friendly label |
| `description` | `"description"` | The attribute to use as a description |
| `depth`[^1] | `"_depth"` | The attribute which indicates an object's depth within a recursive hierarchy |
| `disabled` | -- | The attribute which, if true, signifies that the option should be disabled |
| `parent` | -- | The attribute which represents the object's parent object |
| `count`[^1] | -- | The attribute which contains a numeric count of related objects |
[^1]: The value of this attribute must be a positive integer
### MultiObjectVar ### MultiObjectVar
Similar to `ObjectVar`, but allows for the selection of multiple objects. Similar to `ObjectVar`, but allows for the selection of multiple objects.

View File

@ -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. 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 ### Device Type

View File

@ -1,6 +1,41 @@
# NetBox v3.7 # NetBox v3.7
## v3.7.3 (FUTURE) ## v3.7.4 (FUTURE)
---
## v3.7.3 (2024-02-21)
### Enhancements
* [#14587](https://github.com/netbox-community/netbox/issues/14587) - Display a human-friendly name for the OpenID Connect remote auth backend
* [#14946](https://github.com/netbox-community/netbox/issues/14946) - Remove `associate_by_email()` from default social auth pipeline
* [#14966](https://github.com/netbox-community/netbox/issues/14966) - Add PostgreSQL index for object type & ID on CachedValue table to improve performance
* [#15177](https://github.com/netbox-community/netbox/issues/15177) - Add "last login" time to user display & REST API serializer
### Bug Fixes
* [#14058](https://github.com/netbox-community/netbox/issues/14058) - Limit platform options by manufacturer when editing a device or device type
* [#14064](https://github.com/netbox-community/netbox/issues/14064) - Resolving parent location should consider assigned site when bulk importing locations
* [#14079](https://github.com/netbox-community/netbox/issues/14079) - Ensure changes are logged on related objects when deleting an object referenced via a many-to-many relationship (e.g. tags)
* [#14405](https://github.com/netbox-community/netbox/issues/14405) - Clean up formatting of link peers in bulk CSV export of cable termination objects
* [#14689](https://github.com/netbox-community/netbox/issues/14689) - Preserve "empty" default values for JSON custom fields
* [#14952](https://github.com/netbox-community/netbox/issues/14952) - Update existing AutoSyncRecord when changing the data file of an auto-synced object
* [#15059](https://github.com/netbox-community/netbox/issues/15059) - Correct IP address count link in VM interfaces table
* [#15067](https://github.com/netbox-community/netbox/issues/15067) - Fix uncaught exception when attempting invalid device bay import
* [#15070](https://github.com/netbox-community/netbox/issues/15070) - Fix inclusion of `config_template` field on REST API serializer for virtual machines
* [#15084](https://github.com/netbox-community/netbox/issues/15084) - Fix "add export template" link under "export" button on object list views
* [#15090](https://github.com/netbox-community/netbox/issues/15090) - Ensure protection rules are evaluated prior to enqueueing events when deleting an object
* [#15091](https://github.com/netbox-community/netbox/issues/15091) - Fix designation of the active tab for assigned object when modifying an L2VPN termination
* [#15101](https://github.com/netbox-community/netbox/issues/15101) - Correct OpenAPI schema for rack elevation REST API endpoint
* [#15115](https://github.com/netbox-community/netbox/issues/15115) - Fix unhandled exception with invalid permission constraints
* [#15126](https://github.com/netbox-community/netbox/issues/15126) - `group` field should be optional when creating VPN tunnel via REST API
* [#15127](https://github.com/netbox-community/netbox/issues/15127) - Add missing group column to VPN tunnels table
* [#15133](https://github.com/netbox-community/netbox/issues/15133) - Fix FHRP group representation on assignments REST API endpoint using brief mode
* [#15174](https://github.com/netbox-community/netbox/issues/15174) - Warn that permission constraints are not supported for reports or scripts
* [#15184](https://github.com/netbox-community/netbox/issues/15184) - Correct REST API schema definition for `front_image` & `rear_image` on DeviceType
* [#15185](https://github.com/netbox-community/netbox/issues/15185) - Ensure error messages pertaining to related objects are displayed on the bulk import form
* [#15192](https://github.com/netbox-community/netbox/issues/15192) - Fix exception when viewing current config when no history is present
--- ---

View File

@ -2,23 +2,38 @@
## v4.0.0 (FUTURE) ## v4.0.0 (FUTURE)
### Breaking Changes
* The deprecated `device_role` & `device_role_id` filters for devices have been removed. (Use `role` and `role_id` instead.)
* The legacy reports functionality has been dropped. Reports will be automatically converted to custom scripts on upgrade.
### New Features ### New Features
#### Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128)) #### Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128))
The NetBox user interface has been completely refreshed and updated. The NetBox user interface has been completely refreshed and updated.
#### Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087))
The REST API now supports specifying which fields to include in the response data.
### Enhancements ### Enhancements
* [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3 * [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
* [#13283](https://github.com/netbox-community/netbox/issues/13283) - Display additional context on API-backed dropdown fields
* [#14237](https://github.com/netbox-community/netbox/issues/14237) - Automatically clear dependent selection fields when modifying a parent selection
* [#14637](https://github.com/netbox-community/netbox/issues/14637) - Upgrade to Django 5.0 * [#14637](https://github.com/netbox-community/netbox/issues/14637) - Upgrade to Django 5.0
* [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12 * [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12
* [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI * [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI
* [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI * [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI
* [#14438](https://github.com/netbox-community/netbox/issues/14438) - Track individual custom scripts as database objects
* [#15131](https://github.com/netbox-community/netbox/issues/15131) - Automatically annotate related object counts on REST API querysets
* [#15238](https://github.com/netbox-community/netbox/issues/15238) - Include the `description` field in "brief" REST API serializations
### Other Changes ### Other Changes
* [#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) * [#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 a custom User model rather than the stock model provided by Django
* [#13647](https://github.com/netbox-community/netbox/issues/13647) - Squash all database migrations prior to v3.7 * [#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`) * [#14092](https://github.com/netbox-community/netbox/issues/14092) - Remove backward compatibility for importing plugin resources from `extras.plugins` (now `netbox.plugins`)
@ -26,3 +41,6 @@ The NetBox user interface has been completely refreshed and updated.
* [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin` * [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin`
* [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`) * [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`)
* [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class * [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class
* [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features
* [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices
* [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class

View File

@ -292,6 +292,7 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md' - git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes: - Release Notes:
- Summary: 'release-notes/index.md' - Summary: 'release-notes/index.md'
- Version 4.0: 'release-notes/version-4.0.md'
- Version 3.7: 'release-notes/version-3.7.md' - Version 3.7: 'release-notes/version-3.7.md'
- Version 3.6: 'release-notes/version-3.6.md' - Version 3.6: 'release-notes/version-3.6.md'
- Version 3.5: 'release-notes/version-3.5.md' - Version 3.5: 'release-notes/version-3.5.md'

View File

@ -1,8 +1,8 @@
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer from drf_spectacular.utils import extend_schema_serializer
from drf_spectacular.types import OpenApiTypes
from rest_framework import serializers from rest_framework import serializers
from circuits.models import * from circuits.models import *
from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import WritableNestedSerializer from netbox.api.serializers import WritableNestedSerializer
__all__ = [ __all__ = [
@ -36,7 +36,7 @@ class NestedProviderNetworkSerializer(WritableNestedSerializer):
) )
class NestedProviderSerializer(WritableNestedSerializer): class NestedProviderSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
circuit_count = serializers.IntegerField(read_only=True) circuit_count = RelatedObjectCountField('circuits')
class Meta: class Meta:
model = Provider model = Provider
@ -64,7 +64,7 @@ class NestedProviderAccountSerializer(WritableNestedSerializer):
) )
class NestedCircuitTypeSerializer(WritableNestedSerializer): class NestedCircuitTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = serializers.IntegerField(read_only=True) circuit_count = RelatedObjectCountField('circuits')
class Meta: class Meta:
model = CircuitType model = CircuitType

View File

@ -4,9 +4,9 @@ from circuits.choices import CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.api.nested_serializers import NestedSiteSerializer from dcim.api.nested_serializers import NestedSiteSerializer
from dcim.api.serializers import CabledObjectSerializer from dcim.api.serializers import CabledObjectSerializer
from ipam.models import ASN
from ipam.api.nested_serializers import NestedASNSerializer from ipam.api.nested_serializers import NestedASNSerializer
from netbox.api.fields import ChoiceField, SerializedPKRelatedField from ipam.models import ASN
from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import * from .nested_serializers import *
@ -32,7 +32,7 @@ class ProviderSerializer(NetBoxModelSerializer):
) )
# Related object counts # Related object counts
circuit_count = serializers.IntegerField(read_only=True) circuit_count = RelatedObjectCountField('circuits')
class Meta: class Meta:
model = Provider model = Provider
@ -40,6 +40,7 @@ class ProviderSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags', 'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count', 'custom_fields', 'created', 'last_updated', 'circuit_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
# #
@ -56,6 +57,7 @@ class ProviderAccountSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
# #
@ -72,6 +74,7 @@ class ProviderNetworkSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags', 'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -80,14 +83,17 @@ class ProviderNetworkSerializer(NetBoxModelSerializer):
class CircuitTypeSerializer(NetBoxModelSerializer): class CircuitTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = serializers.IntegerField(read_only=True)
# Related object counts
circuit_count = RelatedObjectCountField('circuits')
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'circuit_count', 'last_updated', 'circuit_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
class CircuitCircuitTerminationSerializer(WritableNestedSerializer): class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
@ -120,6 +126,7 @@ class CircuitSerializer(NetBoxModelSerializer):
'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'cid', 'description')
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer): class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
@ -135,3 +142,4 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')

View File

@ -4,7 +4,6 @@ from circuits import filtersets
from circuits.models import * from circuits.models import *
from dcim.api.views import PassThroughPortMixin from dcim.api.views import PassThroughPortMixin
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from utilities.utils import count_related
from . import serializers from . import serializers
@ -21,9 +20,7 @@ class CircuitsRootView(APIRootView):
# #
class ProviderViewSet(NetBoxModelViewSet): class ProviderViewSet(NetBoxModelViewSet):
queryset = Provider.objects.prefetch_related('asns', 'tags').annotate( queryset = Provider.objects.all()
circuit_count=count_related(Circuit, 'provider')
)
serializer_class = serializers.ProviderSerializer serializer_class = serializers.ProviderSerializer
filterset_class = filtersets.ProviderFilterSet filterset_class = filtersets.ProviderFilterSet
@ -33,9 +30,7 @@ class ProviderViewSet(NetBoxModelViewSet):
# #
class CircuitTypeViewSet(NetBoxModelViewSet): class CircuitTypeViewSet(NetBoxModelViewSet):
queryset = CircuitType.objects.prefetch_related('tags').annotate( queryset = CircuitType.objects.all()
circuit_count=count_related(Circuit, 'type')
)
serializer_class = serializers.CircuitTypeSerializer serializer_class = serializers.CircuitTypeSerializer
filterset_class = filtersets.CircuitTypeFilterSet filterset_class = filtersets.CircuitTypeFilterSet
@ -45,9 +40,7 @@ class CircuitTypeViewSet(NetBoxModelViewSet):
# #
class CircuitViewSet(NetBoxModelViewSet): class CircuitViewSet(NetBoxModelViewSet):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.all()
'type', 'tenant', 'provider', 'provider_account', 'termination_a', 'termination_z'
).prefetch_related('tags')
serializer_class = serializers.CircuitSerializer serializer_class = serializers.CircuitSerializer
filterset_class = filtersets.CircuitFilterSet filterset_class = filtersets.CircuitFilterSet
@ -57,12 +50,9 @@ class CircuitViewSet(NetBoxModelViewSet):
# #
class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet): class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = CircuitTermination.objects.prefetch_related( queryset = CircuitTermination.objects.all()
'circuit', 'site', 'provider_network', 'cable__terminations'
)
serializer_class = serializers.CircuitTerminationSerializer serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filtersets.CircuitTerminationFilterSet filterset_class = filtersets.CircuitTerminationFilterSet
brief_prefetch_fields = ['circuit']
# #
@ -70,7 +60,7 @@ class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
# #
class ProviderAccountViewSet(NetBoxModelViewSet): class ProviderAccountViewSet(NetBoxModelViewSet):
queryset = ProviderAccount.objects.prefetch_related('provider', 'tags') queryset = ProviderAccount.objects.all()
serializer_class = serializers.ProviderAccountSerializer serializer_class = serializers.ProviderAccountSerializer
filterset_class = filtersets.ProviderAccountFilterSet filterset_class = filtersets.ProviderAccountFilterSet
@ -80,6 +70,6 @@ class ProviderAccountViewSet(NetBoxModelViewSet):
# #
class ProviderNetworkViewSet(NetBoxModelViewSet): class ProviderNetworkViewSet(NetBoxModelViewSet):
queryset = ProviderNetwork.objects.prefetch_related('tags') queryset = ProviderNetwork.objects.all()
serializer_class = serializers.ProviderNetworkSerializer serializer_class = serializers.ProviderNetworkSerializer
filterset_class = filtersets.ProviderNetworkFilterSet filterset_class = filtersets.ProviderNetworkFilterSet

View File

@ -6,4 +6,8 @@ class CircuitsConfig(AppConfig):
verbose_name = "Circuits" verbose_name = "Circuits"
def ready(self): def ready(self):
from netbox.models.features import register_models
from . import signals, search from . import signals, search
# Register models
register_models(*self.get_models())

View File

@ -234,9 +234,9 @@ class CircuitTermination(
# Must define either site *or* provider network # Must define either site *or* provider network
if self.site is None and self.provider_network is None: if self.site is None and self.provider_network is None:
raise ValidationError("A circuit termination must attach to either a site or a provider network.") raise ValidationError(_("A circuit termination must attach to either a site or a provider network."))
if self.site and self.provider_network: if self.site and self.provider_network:
raise ValidationError("A circuit termination cannot attach to both a site and a provider network.") raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network."))
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)

View File

@ -18,7 +18,7 @@ class AppTest(APITestCase):
class ProviderTest(APIViewTestCases.APIViewTestCase): class ProviderTest(APIViewTestCases.APIViewTestCase):
model = Provider model = Provider
brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
bulk_update_data = { bulk_update_data = {
'comments': 'New comments', 'comments': 'New comments',
} }
@ -60,7 +60,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
class CircuitTypeTest(APIViewTestCases.APIViewTestCase): class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
model = CircuitType model = CircuitType
brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
create_data = ( create_data = (
{ {
'name': 'Circuit Type 4', 'name': 'Circuit Type 4',
@ -92,7 +92,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
class CircuitTest(APIViewTestCases.APIViewTestCase): class CircuitTest(APIViewTestCases.APIViewTestCase):
model = Circuit model = Circuit
brief_fields = ['cid', 'display', 'id', 'url'] brief_fields = ['cid', 'description', 'display', 'id', 'url']
bulk_update_data = { bulk_update_data = {
'status': 'planned', 'status': 'planned',
} }
@ -149,7 +149,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
model = CircuitTermination model = CircuitTermination
brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url'] brief_fields = ['_occupied', 'cable', 'circuit', 'description', 'display', 'id', 'term_side', 'url']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -208,7 +208,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
class ProviderAccountTest(APIViewTestCases.APIViewTestCase): class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
model = ProviderAccount model = ProviderAccount
brief_fields = ['account', 'display', 'id', 'name', 'url'] brief_fields = ['account', 'description', 'display', 'id', 'name', 'url']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -251,7 +251,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
class ProviderNetworkTest(APIViewTestCases.APIViewTestCase): class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
model = ProviderNetwork model = ProviderNetwork
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -8,6 +8,7 @@ from drf_spectacular.plumbing import (
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc, build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
) )
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from rest_framework import serializers
from rest_framework.relations import ManyRelatedField from rest_framework.relations import ManyRelatedField
from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, SerializedPKRelatedField

View File

@ -2,7 +2,7 @@ from rest_framework import serializers
from core.choices import * from core.choices import *
from core.models import * from core.models import *
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
@ -28,9 +28,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
) )
# Related object counts # Related object counts
file_count = serializers.IntegerField( file_count = RelatedObjectCountField('datafiles')
read_only=True
)
class Meta: class Meta:
model = DataSource model = DataSource
@ -38,6 +36,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments', 'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count', 'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class DataFileSerializer(NetBoxModelSerializer): class DataFileSerializer(NetBoxModelSerializer):
@ -53,6 +52,7 @@ class DataFileSerializer(NetBoxModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash', 'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
] ]
brief_fields = ('id', 'url', 'display', 'path')
class JobSerializer(BaseModelSerializer): class JobSerializer(BaseModelSerializer):
@ -71,3 +71,4 @@ class JobSerializer(BaseModelSerializer):
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval', 'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
'started', 'completed', 'user', 'data', 'error', 'job_id', 'started', 'completed', 'user', 'data', 'error', 'job_id',
] ]
brief_fields = ('url', 'created', 'completed', 'user', 'status')

View File

@ -9,7 +9,6 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
from core import filtersets from core import filtersets
from core.models import * from core.models import *
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
from utilities.utils import count_related
from . import serializers from . import serializers
@ -22,9 +21,7 @@ class CoreRootView(APIRootView):
class DataSourceViewSet(NetBoxModelViewSet): class DataSourceViewSet(NetBoxModelViewSet):
queryset = DataSource.objects.annotate( queryset = DataSource.objects.all()
file_count=count_related(DataFile, 'source')
)
serializer_class = serializers.DataSourceSerializer serializer_class = serializers.DataSourceSerializer
filterset_class = filtersets.DataSourceFilterSet filterset_class = filtersets.DataSourceFilterSet
@ -44,7 +41,7 @@ class DataSourceViewSet(NetBoxModelViewSet):
class DataFileViewSet(NetBoxReadOnlyModelViewSet): class DataFileViewSet(NetBoxReadOnlyModelViewSet):
queryset = DataFile.objects.defer('data').prefetch_related('source') queryset = DataFile.objects.defer('data')
serializer_class = serializers.DataFileSerializer serializer_class = serializers.DataFileSerializer
filterset_class = filtersets.DataFileFilterSet filterset_class = filtersets.DataFileFilterSet
@ -53,6 +50,6 @@ class JobViewSet(ReadOnlyModelViewSet):
""" """
Retrieve a list of job results Retrieve a list of job results
""" """
queryset = Job.objects.prefetch_related('user') queryset = Job.objects.all()
serializer_class = serializers.JobSerializer serializer_class = serializers.JobSerializer
filterset_class = filtersets.JobFilterSet filterset_class = filtersets.JobFilterSet

View File

@ -16,5 +16,9 @@ class CoreConfig(AppConfig):
name = "core" name = "core"
def ready(self): def ready(self):
from core.api import schema # noqa
from netbox.models.features import register_models
from . import data_backends, search from . import data_backends, search
from core.api import schema # noqa: E402
# Register models
register_models(*self.get_models())

View File

@ -102,7 +102,7 @@ class GitBackend(DataBackend):
try: try:
porcelain.clone(self.url, local_path.name, **clone_args) porcelain.clone(self.url, local_path.name, **clone_args)
except BaseException as e: except BaseException as e:
raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}") raise SyncError(_("Fetching remote data failed ({name}): {error}").format(name=type(e).__name__, error=e))
yield local_path.name yield local_path.name

View File

@ -103,9 +103,9 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
super().clean() super().clean()
if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'): if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'):
raise forms.ValidationError("Cannot upload a file and sync from an existing file") raise forms.ValidationError(_("Cannot upload a file and sync from an existing file"))
if not self.cleaned_data.get('upload_file') and not self.cleaned_data.get('data_file'): if not self.cleaned_data.get('upload_file') and not self.cleaned_data.get('data_file'):
raise forms.ValidationError("Must upload a file or select a data file to sync") raise forms.ValidationError(_("Must upload a file or select a data file to sync"))
return self.cleaned_data return self.cleaned_data

View File

@ -44,7 +44,7 @@ class ConfigRevision(models.Model):
return gettext('Config revision #{id}').format(id=self.pk) return gettext('Config revision #{id}').format(id=self.pk)
def __getattr__(self, item): def __getattr__(self, item):
if item in self.data: if self.data and item in self.data:
return self.data[item] return self.data[item]
return super().__getattribute__(item) return super().__getattribute__(item)

View File

@ -177,7 +177,7 @@ class DataSource(JobsMixin, PrimaryModel):
Create/update/delete child DataFiles as necessary to synchronize with the remote source. Create/update/delete child DataFiles as necessary to synchronize with the remote source.
""" """
if self.status == DataSourceStatusChoices.SYNCING: if self.status == DataSourceStatusChoices.SYNCING:
raise SyncError("Cannot initiate sync; syncing already in progress.") raise SyncError(_("Cannot initiate sync; syncing already in progress."))
# Emit the pre_sync signal # Emit the pre_sync signal
pre_sync.send(sender=self.__class__, instance=self) pre_sync.send(sender=self.__class__, instance=self)
@ -190,7 +190,7 @@ class DataSource(JobsMixin, PrimaryModel):
backend = self.get_backend() backend = self.get_backend()
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
raise SyncError( raise SyncError(
f"There was an error initializing the backend. A dependency needs to be installed: {e}" _("There was an error initializing the backend. A dependency needs to be installed: ") + str(e)
) )
with backend.fetch() as local_path: with backend.fetch() as local_path:

View File

@ -181,7 +181,11 @@ class Job(models.Model):
""" """
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
if status not in valid_statuses: if status not in valid_statuses:
raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}") raise ValueError(
_("Invalid status for job termination. Choices are: {choices}").format(
choices=', '.join(valid_statuses)
)
)
# Mark the job as completed # Mark the job as completed
self.status = status self.status = status

View File

@ -16,7 +16,7 @@ class AppTest(APITestCase):
class DataSourceTest(APIViewTestCases.APIViewTestCase): class DataSourceTest(APIViewTestCases.APIViewTestCase):
model = DataSource model = DataSource
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'enabled': False, 'enabled': False,
'description': 'foo bar baz', 'description': 'foo bar baz',

View File

@ -184,7 +184,7 @@ class ConfigView(generic.ObjectView):
except ConfigRevision.DoesNotExist: except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found # Fall back to using the active config data if no record is found
return ConfigRevision( return ConfigRevision(
data=get_config() data=get_config().defaults
) )

View File

@ -2,7 +2,8 @@ from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers from rest_framework import serializers
from dcim import models from dcim import models
from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import WritableNestedSerializer
__all__ = [ __all__ = [
'ComponentNestedModuleSerializer', 'ComponentNestedModuleSerializer',
@ -110,7 +111,7 @@ class NestedLocationSerializer(WritableNestedSerializer):
) )
class NestedRackRoleSerializer(WritableNestedSerializer): class NestedRackRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = serializers.IntegerField(read_only=True) rack_count = RelatedObjectCountField('racks')
class Meta: class Meta:
model = models.RackRole model = models.RackRole
@ -122,7 +123,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
) )
class NestedRackSerializer(WritableNestedSerializer): class NestedRackSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
device_count = serializers.IntegerField(read_only=True) device_count = RelatedObjectCountField('devices')
class Meta: class Meta:
model = models.Rack model = models.Rack
@ -150,7 +151,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer):
) )
class NestedManufacturerSerializer(WritableNestedSerializer): class NestedManufacturerSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = serializers.IntegerField(read_only=True) devicetype_count = RelatedObjectCountField('device_types')
class Meta: class Meta:
model = models.Manufacturer model = models.Manufacturer
@ -163,7 +164,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
class NestedDeviceTypeSerializer(WritableNestedSerializer): class NestedDeviceTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer(read_only=True) manufacturer = NestedManufacturerSerializer(read_only=True)
device_count = serializers.IntegerField(read_only=True) device_count = RelatedObjectCountField('instances')
class Meta: class Meta:
model = models.DeviceType model = models.DeviceType
@ -173,7 +174,6 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
class NestedModuleTypeSerializer(WritableNestedSerializer): class NestedModuleTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
manufacturer = NestedManufacturerSerializer(read_only=True) manufacturer = NestedManufacturerSerializer(read_only=True)
# module_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = models.ModuleType model = models.ModuleType
@ -274,8 +274,8 @@ class NestedInventoryItemTemplateSerializer(WritableNestedSerializer):
) )
class NestedDeviceRoleSerializer(WritableNestedSerializer): class NestedDeviceRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
device_count = serializers.IntegerField(read_only=True) device_count = RelatedObjectCountField('devices')
virtualmachine_count = serializers.IntegerField(read_only=True) virtualmachine_count = RelatedObjectCountField('virtual_machines')
class Meta: class Meta:
model = models.DeviceRole model = models.DeviceRole
@ -287,8 +287,8 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer):
) )
class NestedPlatformSerializer(WritableNestedSerializer): class NestedPlatformSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
device_count = serializers.IntegerField(read_only=True) device_count = RelatedObjectCountField('devices')
virtualmachine_count = serializers.IntegerField(read_only=True) virtualmachine_count = RelatedObjectCountField('virtual_machines')
class Meta: class Meta:
model = models.Platform model = models.Platform
@ -445,7 +445,7 @@ class NestedInventoryItemSerializer(WritableNestedSerializer):
) )
class NestedInventoryItemRoleSerializer(WritableNestedSerializer): class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
inventoryitem_count = serializers.IntegerField(read_only=True) inventoryitem_count = RelatedObjectCountField('inventory_items')
class Meta: class Meta:
model = models.InventoryItemRole model = models.InventoryItemRole
@ -490,7 +490,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
) )
class NestedPowerPanelSerializer(WritableNestedSerializer): class NestedPowerPanelSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
powerfeed_count = serializers.IntegerField(read_only=True) powerfeed_count = RelatedObjectCountField('powerfeeds')
class Meta: class Meta:
model = models.PowerPanel model = models.PowerPanel

View File

@ -15,7 +15,7 @@ from ipam.api.nested_serializers import (
NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
) )
from ipam.models import ASN, VLAN from ipam.models import ASN, VLAN
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import ( from netbox.api.serializers import (
GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer,
WritableNestedSerializer, WritableNestedSerializer,
@ -114,6 +114,7 @@ class RegionSerializer(NestedGroupModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'site_count', '_depth', 'last_updated', 'site_count', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
class SiteGroupSerializer(NestedGroupModelSerializer): class SiteGroupSerializer(NestedGroupModelSerializer):
@ -127,6 +128,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'site_count', '_depth', 'last_updated', 'site_count', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
class SiteSerializer(NetBoxModelSerializer): class SiteSerializer(NetBoxModelSerializer):
@ -144,12 +146,12 @@ class SiteSerializer(NetBoxModelSerializer):
) )
# Related object counts # Related object counts
circuit_count = serializers.IntegerField(read_only=True) circuit_count = RelatedObjectCountField('circuit_terminations')
device_count = serializers.IntegerField(read_only=True) device_count = RelatedObjectCountField('devices')
prefix_count = serializers.IntegerField(read_only=True) prefix_count = RelatedObjectCountField('prefixes')
rack_count = serializers.IntegerField(read_only=True) rack_count = RelatedObjectCountField('racks')
virtualmachine_count = serializers.IntegerField(read_only=True) vlan_count = RelatedObjectCountField('vlans')
vlan_count = serializers.IntegerField(read_only=True) virtualmachine_count = RelatedObjectCountField('virtual_machines')
class Meta: class Meta:
model = Site model = Site
@ -159,6 +161,7 @@ class SiteSerializer(NetBoxModelSerializer):
'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
'virtualmachine_count', 'vlan_count', 'virtualmachine_count', 'vlan_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
# #
@ -180,11 +183,14 @@ class LocationSerializer(NestedGroupModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags', 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
class RackRoleSerializer(NetBoxModelSerializer): class RackRoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = serializers.IntegerField(read_only=True)
# Related object counts
rack_count = RelatedObjectCountField('racks')
class Meta: class Meta:
model = RackRole model = RackRole
@ -192,6 +198,7 @@ class RackRoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'rack_count', 'last_updated', 'rack_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
class RackSerializer(NetBoxModelSerializer): class RackSerializer(NetBoxModelSerializer):
@ -207,8 +214,10 @@ class RackSerializer(NetBoxModelSerializer):
width = ChoiceField(choices=RackWidthChoices, required=False) width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True) 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) weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True) # Related object counts
device_count = RelatedObjectCountField('devices')
powerfeed_count = RelatedObjectCountField('powerfeeds')
class Meta: class Meta:
model = Rack model = Rack
@ -218,6 +227,7 @@ class RackSerializer(NetBoxModelSerializer):
'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
class RackUnitSerializer(serializers.Serializer): class RackUnitSerializer(serializers.Serializer):
@ -252,6 +262,7 @@ class RackReservationSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
'comments', 'tags', 'custom_fields', 'comments', 'tags', 'custom_fields',
] ]
brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
class RackElevationDetailFilterSerializer(serializers.Serializer): class RackElevationDetailFilterSerializer(serializers.Serializer):
@ -299,9 +310,11 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
class ManufacturerSerializer(NetBoxModelSerializer): class ManufacturerSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = serializers.IntegerField(read_only=True)
inventoryitem_count = serializers.IntegerField(read_only=True) # Related object counts
platform_count = serializers.IntegerField(read_only=True) devicetype_count = RelatedObjectCountField('device_types')
inventoryitem_count = RelatedObjectCountField('inventory_items')
platform_count = RelatedObjectCountField('platforms')
class Meta: class Meta:
model = Manufacturer model = Manufacturer
@ -309,6 +322,7 @@ class ManufacturerSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'devicetype_count', 'inventoryitem_count', 'platform_count', 'devicetype_count', 'inventoryitem_count', 'platform_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
class DeviceTypeSerializer(NetBoxModelSerializer): class DeviceTypeSerializer(NetBoxModelSerializer):
@ -325,7 +339,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True) 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) 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) weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True) front_image = serializers.URLField(allow_null=True, required=False)
rear_image = serializers.URLField(allow_null=True, required=False)
# Counter fields # Counter fields
console_port_template_count = serializers.IntegerField(read_only=True) console_port_template_count = serializers.IntegerField(read_only=True)
@ -339,6 +354,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
module_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) inventory_item_template_count = serializers.IntegerField(read_only=True)
# Related object counts
device_count = RelatedObjectCountField('instances')
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
@ -350,6 +368,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count', 'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count', 'inventory_item_template_count',
] ]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
class ModuleTypeSerializer(NetBoxModelSerializer): class ModuleTypeSerializer(NetBoxModelSerializer):
@ -363,6 +382,7 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
# #
@ -393,6 +413,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@ -419,6 +440,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class PowerPortTemplateSerializer(ValidatedModelSerializer): class PowerPortTemplateSerializer(ValidatedModelSerializer):
@ -446,6 +468,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
'allocated_draw', 'description', 'created', 'last_updated', 'allocated_draw', 'description', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class PowerOutletTemplateSerializer(ValidatedModelSerializer): class PowerOutletTemplateSerializer(ValidatedModelSerializer):
@ -483,6 +506,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
'description', 'created', 'last_updated', 'description', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class InterfaceTemplateSerializer(ValidatedModelSerializer): class InterfaceTemplateSerializer(ValidatedModelSerializer):
@ -527,6 +551,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated', 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class RearPortTemplateSerializer(ValidatedModelSerializer): class RearPortTemplateSerializer(ValidatedModelSerializer):
@ -549,6 +574,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
'description', 'created', 'last_updated', 'description', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class FrontPortTemplateSerializer(ValidatedModelSerializer): class FrontPortTemplateSerializer(ValidatedModelSerializer):
@ -572,6 +598,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'created', 'last_updated', 'rear_port_position', 'description', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ModuleBayTemplateSerializer(ValidatedModelSerializer): class ModuleBayTemplateSerializer(ValidatedModelSerializer):
@ -584,6 +611,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created', 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class DeviceBayTemplateSerializer(ValidatedModelSerializer): class DeviceBayTemplateSerializer(ValidatedModelSerializer):
@ -593,6 +621,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = DeviceBayTemplate model = DeviceBayTemplate
fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
brief_fields = ('id', 'url', 'display', 'name', 'description')
class InventoryItemTemplateSerializer(ValidatedModelSerializer): class InventoryItemTemplateSerializer(ValidatedModelSerializer):
@ -619,6 +648,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth', '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)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_component(self, obj): def get_component(self, obj):
@ -636,8 +666,10 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
class DeviceRoleSerializer(NetBoxModelSerializer): class DeviceRoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True) # Related object counts
device_count = RelatedObjectCountField('devices')
virtualmachine_count = RelatedObjectCountField('virtual_machines')
class Meta: class Meta:
model = DeviceRole model = DeviceRole
@ -645,14 +677,17 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
class PlatformSerializer(NetBoxModelSerializer): class PlatformSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True) # Related object counts
device_count = RelatedObjectCountField('devices')
virtualmachine_count = RelatedObjectCountField('virtual_machines')
class Meta: class Meta:
model = Platform model = Platform
@ -660,13 +695,13 @@ class PlatformSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
class DeviceSerializer(NetBoxModelSerializer): class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
role = NestedDeviceRoleSerializer() 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) tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer() site = NestedSiteSerializer()
@ -708,14 +743,15 @@ class DeviceSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial', 'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count', 'module_bay_count', 'inventory_item_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
@extend_schema_field(NestedDeviceSerializer) @extend_schema_field(NestedDeviceSerializer)
def get_parent_device(self, obj): def get_parent_device(self, obj):
@ -728,22 +764,19 @@ class DeviceSerializer(NetBoxModelSerializer):
data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
return data return data
def get_device_role(self, obj):
return obj.role
class DeviceWithConfigContextSerializer(DeviceSerializer): class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True) config_context = serializers.SerializerMethodField(read_only=True)
class Meta(DeviceSerializer.Meta): class Meta(DeviceSerializer.Meta):
fields = [ fields = [
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial', 'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags',
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count', 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count', 'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
@ -761,7 +794,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
status = ChoiceField(choices=VirtualDeviceContextStatusChoices) status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
# Related object counts # Related object counts
interface_count = serializers.IntegerField(read_only=True) interface_count = RelatedObjectCountField('interfaces')
class Meta: class Meta:
model = VirtualDeviceContext model = VirtualDeviceContext
@ -770,6 +803,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'interface_count', 'interface_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
class ModuleSerializer(NetBoxModelSerializer): class ModuleSerializer(NetBoxModelSerializer):
@ -785,6 +819,7 @@ class ModuleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
# #
@ -817,6 +852,7 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@ -845,6 +881,7 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@ -879,6 +916,7 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@ -903,6 +941,7 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@ -965,6 +1004,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
def validate(self, data): def validate(self, data):
@ -996,6 +1036,7 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class FrontPortRearPortSerializer(WritableNestedSerializer): class FrontPortRearPortSerializer(WritableNestedSerializer):
@ -1026,6 +1067,7 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ModuleBaySerializer(NetBoxModelSerializer): class ModuleBaySerializer(NetBoxModelSerializer):
@ -1037,9 +1079,9 @@ class ModuleBaySerializer(NetBoxModelSerializer):
model = ModuleBay model = ModuleBay
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
'custom_fields', 'custom_fields', 'created', 'last_updated',
'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
class DeviceBaySerializer(NetBoxModelSerializer): class DeviceBaySerializer(NetBoxModelSerializer):
@ -1053,6 +1095,7 @@ class DeviceBaySerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags', 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
class InventoryItemSerializer(NetBoxModelSerializer): class InventoryItemSerializer(NetBoxModelSerializer):
@ -1076,6 +1119,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags', 'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
'custom_fields', 'created', 'last_updated', '_depth', 'custom_fields', 'created', 'last_updated', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_component(self, obj): def get_component(self, obj):
@ -1092,7 +1136,9 @@ class InventoryItemSerializer(NetBoxModelSerializer):
class InventoryItemRoleSerializer(NetBoxModelSerializer): class InventoryItemRoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
inventoryitem_count = serializers.IntegerField(read_only=True)
# Related object counts
inventoryitem_count = RelatedObjectCountField('inventory_items')
class Meta: class Meta:
model = InventoryItemRole model = InventoryItemRole
@ -1100,6 +1146,7 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'inventoryitem_count', 'last_updated', 'inventoryitem_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')
# #
@ -1120,6 +1167,7 @@ class CableSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color', 'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'label', 'description')
class TracedCableSerializer(serializers.ModelSerializer): class TracedCableSerializer(serializers.ModelSerializer):
@ -1190,6 +1238,7 @@ class VirtualChassisSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'member_count', 'created', 'last_updated', 'member_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')
# #
@ -1204,7 +1253,9 @@ class PowerPanelSerializer(NetBoxModelSerializer):
allow_null=True, allow_null=True,
default=None default=None
) )
powerfeed_count = serializers.IntegerField(read_only=True)
# Related object counts
powerfeed_count = RelatedObjectCountField('powerfeeds')
class Meta: class Meta:
model = PowerPanel model = PowerPanel
@ -1212,6 +1263,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
'powerfeed_count', 'created', 'last_updated', 'powerfeed_count', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@ -1251,3 +1303,4 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')

View File

@ -13,7 +13,6 @@ from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import * from dcim.models import *
from dcim.svg import CableTraceSVG from dcim.svg import CableTraceSVG
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.pagination import StripCountAnnotationsPaginator
@ -23,7 +22,6 @@ from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.models import VirtualMachine
from . import serializers from . import serializers
from .exceptions import MissingFilterException from .exceptions import MissingFilterException
@ -103,7 +101,7 @@ class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
'region', 'region',
'site_count', 'site_count',
cumulative=True cumulative=True
).prefetch_related('tags') )
serializer_class = serializers.RegionSerializer serializer_class = serializers.RegionSerializer
filterset_class = filtersets.RegionFilterSet filterset_class = filtersets.RegionFilterSet
@ -119,7 +117,7 @@ class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
'group', 'group',
'site_count', 'site_count',
cumulative=True cumulative=True
).prefetch_related('tags') )
serializer_class = serializers.SiteGroupSerializer serializer_class = serializers.SiteGroupSerializer
filterset_class = filtersets.SiteGroupFilterSet filterset_class = filtersets.SiteGroupFilterSet
@ -129,16 +127,7 @@ class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
# #
class SiteViewSet(NetBoxModelViewSet): class SiteViewSet(NetBoxModelViewSet):
queryset = Site.objects.prefetch_related( queryset = Site.objects.all()
'region', 'tenant', 'asns', 'tags'
).annotate(
device_count=count_related(Device, 'site'),
rack_count=count_related(Rack, 'site'),
prefix_count=count_related(Prefix, 'site'),
vlan_count=count_related(VLAN, 'site'),
circuit_count=count_related(Circuit, 'terminations__site'),
virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
)
serializer_class = serializers.SiteSerializer serializer_class = serializers.SiteSerializer
filterset_class = filtersets.SiteFilterSet filterset_class = filtersets.SiteFilterSet
@ -160,7 +149,7 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
'location', 'location',
'rack_count', 'rack_count',
cumulative=True cumulative=True
).prefetch_related('site', 'tags') )
serializer_class = serializers.LocationSerializer serializer_class = serializers.LocationSerializer
filterset_class = filtersets.LocationFilterSet filterset_class = filtersets.LocationFilterSet
@ -170,9 +159,7 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
# #
class RackRoleViewSet(NetBoxModelViewSet): class RackRoleViewSet(NetBoxModelViewSet):
queryset = RackRole.objects.prefetch_related('tags').annotate( queryset = RackRole.objects.all()
rack_count=count_related(Rack, 'role')
)
serializer_class = serializers.RackRoleSerializer serializer_class = serializers.RackRoleSerializer
filterset_class = filtersets.RackRoleFilterSet filterset_class = filtersets.RackRoleFilterSet
@ -182,15 +169,16 @@ class RackRoleViewSet(NetBoxModelViewSet):
# #
class RackViewSet(NetBoxModelViewSet): class RackViewSet(NetBoxModelViewSet):
queryset = Rack.objects.prefetch_related( queryset = Rack.objects.all()
'site', 'location', 'role', 'tenant', 'tags'
).annotate(
device_count=count_related(Device, 'rack'),
powerfeed_count=count_related(PowerFeed, 'rack')
)
serializer_class = serializers.RackSerializer serializer_class = serializers.RackSerializer
filterset_class = filtersets.RackFilterSet filterset_class = filtersets.RackFilterSet
@extend_schema(
operation_id='dcim_racks_elevation_retrieve',
filters=False,
parameters=[serializers.RackElevationDetailFilterSerializer],
responses={200: serializers.RackUnitSerializer(many=True)}
)
@action(detail=True) @action(detail=True)
def elevation(self, request, pk=None): def elevation(self, request, pk=None):
""" """
@ -249,7 +237,7 @@ class RackViewSet(NetBoxModelViewSet):
# #
class RackReservationViewSet(NetBoxModelViewSet): class RackReservationViewSet(NetBoxModelViewSet):
queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant') queryset = RackReservation.objects.all()
serializer_class = serializers.RackReservationSerializer serializer_class = serializers.RackReservationSerializer
filterset_class = filtersets.RackReservationFilterSet filterset_class = filtersets.RackReservationFilterSet
@ -259,11 +247,7 @@ class RackReservationViewSet(NetBoxModelViewSet):
# #
class ManufacturerViewSet(NetBoxModelViewSet): class ManufacturerViewSet(NetBoxModelViewSet):
queryset = Manufacturer.objects.prefetch_related('tags').annotate( queryset = Manufacturer.objects.all()
devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
serializer_class = serializers.ManufacturerSerializer serializer_class = serializers.ManufacturerSerializer
filterset_class = filtersets.ManufacturerFilterSet filterset_class = filtersets.ManufacturerFilterSet
@ -273,21 +257,15 @@ class ManufacturerViewSet(NetBoxModelViewSet):
# #
class DeviceTypeViewSet(NetBoxModelViewSet): class DeviceTypeViewSet(NetBoxModelViewSet):
queryset = DeviceType.objects.prefetch_related('manufacturer', 'default_platform', 'tags').annotate( queryset = DeviceType.objects.all()
device_count=count_related(Device, 'device_type')
)
serializer_class = serializers.DeviceTypeSerializer serializer_class = serializers.DeviceTypeSerializer
filterset_class = filtersets.DeviceTypeFilterSet filterset_class = filtersets.DeviceTypeFilterSet
brief_prefetch_fields = ['manufacturer']
class ModuleTypeViewSet(NetBoxModelViewSet): class ModuleTypeViewSet(NetBoxModelViewSet):
queryset = ModuleType.objects.prefetch_related('manufacturer', 'tags').annotate( queryset = ModuleType.objects.all()
# module_count=count_related(Module, 'module_type')
)
serializer_class = serializers.ModuleTypeSerializer serializer_class = serializers.ModuleTypeSerializer
filterset_class = filtersets.ModuleTypeFilterSet filterset_class = filtersets.ModuleTypeFilterSet
brief_prefetch_fields = ['manufacturer']
# #
@ -295,61 +273,61 @@ class ModuleTypeViewSet(NetBoxModelViewSet):
# #
class ConsolePortTemplateViewSet(NetBoxModelViewSet): class ConsolePortTemplateViewSet(NetBoxModelViewSet):
queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer') queryset = ConsolePortTemplate.objects.all()
serializer_class = serializers.ConsolePortTemplateSerializer serializer_class = serializers.ConsolePortTemplateSerializer
filterset_class = filtersets.ConsolePortTemplateFilterSet filterset_class = filtersets.ConsolePortTemplateFilterSet
class ConsoleServerPortTemplateViewSet(NetBoxModelViewSet): class ConsoleServerPortTemplateViewSet(NetBoxModelViewSet):
queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer') queryset = ConsoleServerPortTemplate.objects.all()
serializer_class = serializers.ConsoleServerPortTemplateSerializer serializer_class = serializers.ConsoleServerPortTemplateSerializer
filterset_class = filtersets.ConsoleServerPortTemplateFilterSet filterset_class = filtersets.ConsoleServerPortTemplateFilterSet
class PowerPortTemplateViewSet(NetBoxModelViewSet): class PowerPortTemplateViewSet(NetBoxModelViewSet):
queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer') queryset = PowerPortTemplate.objects.all()
serializer_class = serializers.PowerPortTemplateSerializer serializer_class = serializers.PowerPortTemplateSerializer
filterset_class = filtersets.PowerPortTemplateFilterSet filterset_class = filtersets.PowerPortTemplateFilterSet
class PowerOutletTemplateViewSet(NetBoxModelViewSet): class PowerOutletTemplateViewSet(NetBoxModelViewSet):
queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer') queryset = PowerOutletTemplate.objects.all()
serializer_class = serializers.PowerOutletTemplateSerializer serializer_class = serializers.PowerOutletTemplateSerializer
filterset_class = filtersets.PowerOutletTemplateFilterSet filterset_class = filtersets.PowerOutletTemplateFilterSet
class InterfaceTemplateViewSet(NetBoxModelViewSet): class InterfaceTemplateViewSet(NetBoxModelViewSet):
queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer') queryset = InterfaceTemplate.objects.all()
serializer_class = serializers.InterfaceTemplateSerializer serializer_class = serializers.InterfaceTemplateSerializer
filterset_class = filtersets.InterfaceTemplateFilterSet filterset_class = filtersets.InterfaceTemplateFilterSet
class FrontPortTemplateViewSet(NetBoxModelViewSet): class FrontPortTemplateViewSet(NetBoxModelViewSet):
queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer') queryset = FrontPortTemplate.objects.all()
serializer_class = serializers.FrontPortTemplateSerializer serializer_class = serializers.FrontPortTemplateSerializer
filterset_class = filtersets.FrontPortTemplateFilterSet filterset_class = filtersets.FrontPortTemplateFilterSet
class RearPortTemplateViewSet(NetBoxModelViewSet): class RearPortTemplateViewSet(NetBoxModelViewSet):
queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer') queryset = RearPortTemplate.objects.all()
serializer_class = serializers.RearPortTemplateSerializer serializer_class = serializers.RearPortTemplateSerializer
filterset_class = filtersets.RearPortTemplateFilterSet filterset_class = filtersets.RearPortTemplateFilterSet
class ModuleBayTemplateViewSet(NetBoxModelViewSet): class ModuleBayTemplateViewSet(NetBoxModelViewSet):
queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer') queryset = ModuleBayTemplate.objects.all()
serializer_class = serializers.ModuleBayTemplateSerializer serializer_class = serializers.ModuleBayTemplateSerializer
filterset_class = filtersets.ModuleBayTemplateFilterSet filterset_class = filtersets.ModuleBayTemplateFilterSet
class DeviceBayTemplateViewSet(NetBoxModelViewSet): class DeviceBayTemplateViewSet(NetBoxModelViewSet):
queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer') queryset = DeviceBayTemplate.objects.all()
serializer_class = serializers.DeviceBayTemplateSerializer serializer_class = serializers.DeviceBayTemplateSerializer
filterset_class = filtersets.DeviceBayTemplateFilterSet filterset_class = filtersets.DeviceBayTemplateFilterSet
class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet): class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role') queryset = InventoryItemTemplate.objects.all()
serializer_class = serializers.InventoryItemTemplateSerializer serializer_class = serializers.InventoryItemTemplateSerializer
filterset_class = filtersets.InventoryItemTemplateFilterSet filterset_class = filtersets.InventoryItemTemplateFilterSet
@ -359,10 +337,7 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
# #
class DeviceRoleViewSet(NetBoxModelViewSet): class DeviceRoleViewSet(NetBoxModelViewSet):
queryset = DeviceRole.objects.prefetch_related('config_template', 'tags').annotate( queryset = DeviceRole.objects.all()
device_count=count_related(Device, 'role'),
virtualmachine_count=count_related(VirtualMachine, 'role')
)
serializer_class = serializers.DeviceRoleSerializer serializer_class = serializers.DeviceRoleSerializer
filterset_class = filtersets.DeviceRoleFilterSet filterset_class = filtersets.DeviceRoleFilterSet
@ -372,10 +347,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
# #
class PlatformViewSet(NetBoxModelViewSet): class PlatformViewSet(NetBoxModelViewSet):
queryset = Platform.objects.prefetch_related('config_template', 'tags').annotate( queryset = Platform.objects.all()
device_count=count_related(Device, 'platform'),
virtualmachine_count=count_related(VirtualMachine, 'platform')
)
serializer_class = serializers.PlatformSerializer serializer_class = serializers.PlatformSerializer
filterset_class = filtersets.PlatformFilterSet filterset_class = filtersets.PlatformFilterSet
@ -391,8 +363,7 @@ class DeviceViewSet(
NetBoxModelViewSet NetBoxModelViewSet
): ):
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'parent_bay', # Referenced by DeviceSerializer.get_parent_device()
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
) )
filterset_class = filtersets.DeviceFilterSet filterset_class = filtersets.DeviceFilterSet
pagination_class = StripCountAnnotationsPaginator pagination_class = StripCountAnnotationsPaginator
@ -407,31 +378,21 @@ class DeviceViewSet(
Else, return the DeviceWithConfigContextSerializer Else, return the DeviceWithConfigContextSerializer
""" """
request = self.get_serializer_context()['request'] request = self.get_serializer_context()['request']
if request.query_params.get('brief', False): if self.brief or 'config_context' in request.query_params.get('exclude', []):
return serializers.NestedDeviceSerializer
elif 'config_context' in request.query_params.get('exclude', []):
return serializers.DeviceSerializer return serializers.DeviceSerializer
return serializers.DeviceWithConfigContextSerializer return serializers.DeviceWithConfigContextSerializer
class VirtualDeviceContextViewSet(NetBoxModelViewSet): class VirtualDeviceContextViewSet(NetBoxModelViewSet):
queryset = VirtualDeviceContext.objects.prefetch_related( queryset = VirtualDeviceContext.objects.all()
'device__device_type', 'device', 'tenant', 'tags',
).annotate(
interface_count=count_related(Interface, 'vdcs'),
)
serializer_class = serializers.VirtualDeviceContextSerializer serializer_class = serializers.VirtualDeviceContextSerializer
filterset_class = filtersets.VirtualDeviceContextFilterSet filterset_class = filtersets.VirtualDeviceContextFilterSet
class ModuleViewSet(NetBoxModelViewSet): class ModuleViewSet(NetBoxModelViewSet):
queryset = Module.objects.prefetch_related( queryset = Module.objects.all()
'device', 'module_bay', 'module_type__manufacturer', 'tags',
)
serializer_class = serializers.ModuleSerializer serializer_class = serializers.ModuleSerializer
filterset_class = filtersets.ModuleFilterSet filterset_class = filtersets.ModuleFilterSet
@ -442,49 +403,45 @@ class ModuleViewSet(NetBoxModelViewSet):
class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet): class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = ConsolePort.objects.prefetch_related( queryset = ConsolePort.objects.prefetch_related(
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' '_path', 'cable__terminations',
) )
serializer_class = serializers.ConsolePortSerializer serializer_class = serializers.ConsolePortSerializer
filterset_class = filtersets.ConsolePortFilterSet filterset_class = filtersets.ConsolePortFilterSet
brief_prefetch_fields = ['device']
class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related( queryset = ConsoleServerPort.objects.prefetch_related(
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' '_path', 'cable__terminations',
) )
serializer_class = serializers.ConsoleServerPortSerializer serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filtersets.ConsoleServerPortFilterSet filterset_class = filtersets.ConsoleServerPortFilterSet
brief_prefetch_fields = ['device']
class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerPort.objects.prefetch_related( queryset = PowerPort.objects.prefetch_related(
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' '_path', 'cable__terminations',
) )
serializer_class = serializers.PowerPortSerializer serializer_class = serializers.PowerPortSerializer
filterset_class = filtersets.PowerPortFilterSet filterset_class = filtersets.PowerPortFilterSet
brief_prefetch_fields = ['device']
class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerOutlet.objects.prefetch_related( queryset = PowerOutlet.objects.prefetch_related(
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' '_path', 'cable__terminations',
) )
serializer_class = serializers.PowerOutletSerializer serializer_class = serializers.PowerOutletSerializer
filterset_class = filtersets.PowerOutletFilterSet filterset_class = filtersets.PowerOutletFilterSet
brief_prefetch_fields = ['device']
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related( queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans', '_path', 'cable__terminations',
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations', 'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination
'vdcs', 'ip_addresses', # Referenced by Interface.count_ipaddresses()
'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups()
) )
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet filterset_class = filtersets.InterfaceFilterSet
brief_prefetch_fields = ['device']
def get_bulk_destroy_queryset(self): def get_bulk_destroy_queryset(self):
# Ensure child interfaces are deleted prior to their parents # Ensure child interfaces are deleted prior to their parents
@ -493,41 +450,36 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = FrontPort.objects.prefetch_related( queryset = FrontPort.objects.prefetch_related(
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable__terminations', 'tags' 'cable__terminations',
) )
serializer_class = serializers.FrontPortSerializer serializer_class = serializers.FrontPortSerializer
filterset_class = filtersets.FrontPortFilterSet filterset_class = filtersets.FrontPortFilterSet
brief_prefetch_fields = ['device']
class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = RearPort.objects.prefetch_related( queryset = RearPort.objects.prefetch_related(
'device__device_type__manufacturer', 'module__module_bay', 'cable__terminations', 'tags' 'cable__terminations',
) )
serializer_class = serializers.RearPortSerializer serializer_class = serializers.RearPortSerializer
filterset_class = filtersets.RearPortFilterSet filterset_class = filtersets.RearPortFilterSet
brief_prefetch_fields = ['device']
class ModuleBayViewSet(NetBoxModelViewSet): class ModuleBayViewSet(NetBoxModelViewSet):
queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module') queryset = ModuleBay.objects.all()
serializer_class = serializers.ModuleBaySerializer serializer_class = serializers.ModuleBaySerializer
filterset_class = filtersets.ModuleBayFilterSet filterset_class = filtersets.ModuleBayFilterSet
brief_prefetch_fields = ['device']
class DeviceBayViewSet(NetBoxModelViewSet): class DeviceBayViewSet(NetBoxModelViewSet):
queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags') queryset = DeviceBay.objects.all()
serializer_class = serializers.DeviceBaySerializer serializer_class = serializers.DeviceBaySerializer
filterset_class = filtersets.DeviceBayFilterSet filterset_class = filtersets.DeviceBayFilterSet
brief_prefetch_fields = ['device']
class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet): class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags') queryset = InventoryItem.objects.all()
serializer_class = serializers.InventoryItemSerializer serializer_class = serializers.InventoryItemSerializer
filterset_class = filtersets.InventoryItemFilterSet filterset_class = filtersets.InventoryItemFilterSet
brief_prefetch_fields = ['device']
# #
@ -535,9 +487,7 @@ class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
# #
class InventoryItemRoleViewSet(NetBoxModelViewSet): class InventoryItemRoleViewSet(NetBoxModelViewSet):
queryset = InventoryItemRole.objects.prefetch_related('tags').annotate( queryset = InventoryItemRole.objects.all()
inventoryitem_count=count_related(InventoryItem, 'role')
)
serializer_class = serializers.InventoryItemRoleSerializer serializer_class = serializers.InventoryItemRoleSerializer
filterset_class = filtersets.InventoryItemRoleFilterSet filterset_class = filtersets.InventoryItemRoleFilterSet
@ -554,7 +504,7 @@ class CableViewSet(NetBoxModelViewSet):
class CableTerminationViewSet(NetBoxModelViewSet): class CableTerminationViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata metadata_class = ContentTypeMetadata
queryset = CableTermination.objects.prefetch_related('cable', 'termination') queryset = CableTermination.objects.all()
serializer_class = serializers.CableTerminationSerializer serializer_class = serializers.CableTerminationSerializer
filterset_class = filtersets.CableTerminationFilterSet filterset_class = filtersets.CableTerminationFilterSet
@ -564,10 +514,9 @@ class CableTerminationViewSet(NetBoxModelViewSet):
# #
class VirtualChassisViewSet(NetBoxModelViewSet): class VirtualChassisViewSet(NetBoxModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags') queryset = VirtualChassis.objects.all()
serializer_class = serializers.VirtualChassisSerializer serializer_class = serializers.VirtualChassisSerializer
filterset_class = filtersets.VirtualChassisFilterSet filterset_class = filtersets.VirtualChassisFilterSet
brief_prefetch_fields = ['master']
# #
@ -575,11 +524,7 @@ class VirtualChassisViewSet(NetBoxModelViewSet):
# #
class PowerPanelViewSet(NetBoxModelViewSet): class PowerPanelViewSet(NetBoxModelViewSet):
queryset = PowerPanel.objects.prefetch_related( queryset = PowerPanel.objects.all()
'site', 'location'
).annotate(
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
serializer_class = serializers.PowerPanelSerializer serializer_class = serializers.PowerPanelSerializer
filterset_class = filtersets.PowerPanelFilterSet filterset_class = filtersets.PowerPanelFilterSet
@ -590,7 +535,7 @@ class PowerPanelViewSet(NetBoxModelViewSet):
class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet): class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerFeed.objects.prefetch_related( queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack', '_path', 'cable__terminations', 'tags' '_path', 'cable__terminations',
) )
serializer_class = serializers.PowerFeedSerializer serializer_class = serializers.PowerFeedSerializer
filterset_class = filtersets.PowerFeedFilterSet filterset_class = filtersets.PowerFeedFilterSet

View File

@ -8,9 +8,13 @@ class DCIMConfig(AppConfig):
verbose_name = "DCIM" verbose_name = "DCIM"
def ready(self): def ready(self):
from netbox.models.features import register_models
from utilities.counters import connect_counters
from . import signals, search from . import signals, search
from .models import CableTermination, Device, DeviceType, VirtualChassis from .models import CableTermination, Device, DeviceType, VirtualChassis
from utilities.counters import connect_counters
# Register models
register_models(*self.get_models())
# Register denormalized fields # Register denormalized fields
denormalized.register(CableTermination, '_device', { denormalized.register(CableTermination, '_device', {

View File

@ -1,6 +1,7 @@
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.translation import gettext as _
from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
from .lookups import PathContains from .lookups import PathContains
@ -41,7 +42,7 @@ class MACAddressField(models.Field):
try: try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase) return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
except AddrFormatError: except AddrFormatError:
raise ValidationError(f"Invalid MAC address format: {value}") raise ValidationError(_("Invalid MAC address format: {value}").format(value=value))
def db_type(self, connection): def db_type(self, connection):
return 'macaddr' return 'macaddr'
@ -67,7 +68,7 @@ class WWNField(models.Field):
try: try:
return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase) return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
except AddrFormatError: except AddrFormatError:
raise ValidationError(f"Invalid WWN format: {value}") raise ValidationError(_("Invalid WWN format: {value}").format(value=value))
def db_type(self, connection): def db_type(self, connection):
return 'macaddr8' return 'macaddr8'

View File

@ -2,6 +2,8 @@ import django_filters
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from circuits.models import CircuitTermination from circuits.models import CircuitTermination
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
@ -818,6 +820,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
to_field_name='slug', to_field_name='slug',
label=_('Manufacturer (slug)'), label=_('Manufacturer (slug)'),
) )
available_for_device_type = django_filters.ModelChoiceFilter(
queryset=DeviceType.objects.all(),
method='get_for_device_type'
)
config_template_id = django_filters.ModelMultipleChoiceFilter( config_template_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
label=_('Config template (ID)'), label=_('Config template (ID)'),
@ -827,6 +833,14 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
model = Platform 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):
"""
Return all Platforms available for a specific manufacturer based on device type and Platforms not assigned any
manufacturer
"""
return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
class DeviceFilterSet( class DeviceFilterSet(
NetBoxModelFilterSet, NetBoxModelFilterSet,
@ -1288,18 +1302,6 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name', to_field_name='name',
label=_('Virtual Chassis'), label=_('Virtual Chassis'),
) )
# TODO: Remove in v4.0
device_role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__role',
queryset=DeviceRole.objects.all(),
label=_('Device role (ID)'),
)
device_role = django_filters.ModelMultipleChoiceFilter(
field_name='device__role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label=_('Device role (slug)'),
)
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -557,6 +557,9 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
label=_('Device type'), label=_('Device type'),
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
required=False, required=False,
context={
'parent': 'manufacturer',
},
query_params={ query_params={
'manufacturer_id': '$manufacturer' 'manufacturer_id': '$manufacturer'
} }
@ -640,6 +643,9 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
required=False, required=False,
query_params={ query_params={
'manufacturer_id': '$manufacturer' 'manufacturer_id': '$manufacturer'
},
context={
'parent': 'manufacturer',
} }
) )
status = forms.ChoiceField( status = forms.ChoiceField(

View File

@ -159,6 +159,14 @@ class LocationImportForm(NetBoxModelImportForm):
model = Location model = Location
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags') fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit location queryset by assigned site
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
class RackRoleImportForm(NetBoxModelImportForm): class RackRoleImportForm(NetBoxModelImportForm):
slug = SlugField() slug = SlugField()
@ -870,7 +878,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
def clean_vdcs(self): def clean_vdcs(self):
for vdc in self.cleaned_data['vdcs']: for vdc in self.cleaned_data['vdcs']:
if vdc.device != self.cleaned_data['device']: if vdc.device != self.cleaned_data['device']:
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}") raise forms.ValidationError(
_("VDC {vdc} is not assigned to device {device}").format(
vdc=vdc, device=self.cleaned_data['device']
)
)
return self.cleaned_data['vdcs'] return self.cleaned_data['vdcs']
@ -996,7 +1008,7 @@ class DeviceBayImportForm(NetBoxModelImportForm):
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
).exclude(pk=device.pk) ).exclude(pk=device.pk)
else: else:
self.fields['installed_device'].queryset = Interface.objects.none() self.fields['installed_device'].queryset = Device.objects.none()
class InventoryItemImportForm(NetBoxModelImportForm): class InventoryItemImportForm(NetBoxModelImportForm):
@ -1075,7 +1087,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
component = model.objects.get(device=device, name=component_name) component = model.objects.get(device=device, name=component_name)
self.instance.component = component self.instance.component = component
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise forms.ValidationError(f"Component not found: {device} - {component_name}") raise forms.ValidationError(
_("Component not found: {device} - {component_name}").format(
device=device, component_name=component_name
)
)
# #
@ -1193,10 +1209,17 @@ class CableImportForm(NetBoxModelImportForm):
else: else:
termination_object = model.objects.get(device=device, name=name) termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None and termination_object.cable != self.instance: if termination_object.cable is not None and termination_object.cable != self.instance:
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected") raise forms.ValidationError(
_("Side {side_upper}: {device} {termination_object} is already connected").format(
side_upper=side.upper(), device=device, termination_object=termination_object
)
)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}") raise forms.ValidationError(
_("{side_upper} side termination not found: {device} {name}").format(
side_upper=side.upper(), device=device, name=name
)
)
setattr(self.instance, f'{side}_terminations', [termination_object]) setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object return termination_object

View File

@ -30,7 +30,9 @@ def get_cable_form(a_type, b_type):
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(), queryset=term_cls.objects.all(),
label=term_cls._meta.verbose_name.title(), label=term_cls._meta.verbose_name.title(),
disabled_indicator='_occupied', context={
'disabled': '_occupied',
},
query_params={ query_params={
'device_id': f'$termination_{cable_end}_device', 'device_id': f'$termination_{cable_end}_device',
'kind': 'physical', # Exclude virtual interfaces 'kind': 'physical', # Exclude virtual interfaces
@ -52,7 +54,9 @@ def get_cable_form(a_type, b_type):
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(), queryset=term_cls.objects.all(),
label=_('Power Feed'), label=_('Power Feed'),
disabled_indicator='_occupied', context={
'disabled': '_occupied',
},
query_params={ query_params={
'power_panel_id': f'$termination_{cable_end}_powerpanel', 'power_panel_id': f'$termination_{cable_end}_powerpanel',
} }
@ -72,7 +76,9 @@ def get_cable_form(a_type, b_type):
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(), queryset=term_cls.objects.all(),
label=_('Side'), label=_('Side'),
disabled_indicator='_occupied', context={
'disabled': '_occupied',
},
query_params={ query_params={
'circuit_id': f'$termination_{cable_end}_circuit', 'circuit_id': f'$termination_{cable_end}_circuit',
} }

View File

@ -291,7 +291,11 @@ class DeviceTypeForm(NetBoxModelForm):
default_platform = DynamicModelChoiceField( default_platform = DynamicModelChoiceField(
label=_('Default platform'), label=_('Default platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False required=False,
selector=True,
query_params={
'manufacturer_id': ['$manufacturer', 'null'],
}
) )
slug = SlugField( slug = SlugField(
label=_('Slug'), label=_('Slug'),
@ -426,7 +430,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
widget=APISelect( widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/elevation/', api_url='/api/dcim/racks/{{rack}}/elevation/',
attrs={ attrs={
'disabled-indicator': 'device', 'ts-disabled-field': 'device',
'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]' 'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
}, },
) )
@ -434,6 +438,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
device_type = DynamicModelChoiceField( device_type = DynamicModelChoiceField(
label=_('Device type'), label=_('Device type'),
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
context={
'parent': 'manufacturer',
},
selector=True selector=True
) )
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
@ -444,7 +451,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=_('Platform'), label=_('Platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False, required=False,
selector=True selector=True,
query_params={
'available_for_device_type': '$device_type',
}
) )
cluster = DynamicModelChoiceField( cluster = DynamicModelChoiceField(
label=_('Cluster'), label=_('Cluster'),
@ -461,6 +471,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=_('Virtual chassis'), label=_('Virtual chassis'),
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),
required=False, required=False,
context={
'parent': 'master',
},
selector=True selector=True
) )
vc_position = forms.IntegerField( vc_position = forms.IntegerField(
@ -568,6 +581,9 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
module_type = DynamicModelChoiceField( module_type = DynamicModelChoiceField(
label=_('Module type'), label=_('Module type'),
queryset=ModuleType.objects.all(), queryset=ModuleType.objects.all(),
context={
'parent': 'manufacturer',
},
selector=True selector=True
) )
comments = CommentField() comments = CommentField()
@ -774,7 +790,10 @@ class VCMemberSelectForm(forms.Form):
class ComponentTemplateForm(forms.ModelForm): class ComponentTemplateForm(forms.ModelForm):
device_type = DynamicModelChoiceField( device_type = DynamicModelChoiceField(
label=_('Device type'), label=_('Device type'),
queryset=DeviceType.objects.all() queryset=DeviceType.objects.all(),
context={
'parent': 'manufacturer',
}
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -789,12 +808,18 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
device_type = DynamicModelChoiceField( device_type = DynamicModelChoiceField(
label=_('Device type'), label=_('Device type'),
queryset=DeviceType.objects.all().all(), queryset=DeviceType.objects.all().all(),
required=False required=False,
context={
'parent': 'manufacturer',
}
) )
module_type = DynamicModelChoiceField( module_type = DynamicModelChoiceField(
label=_('Module type'), label=_('Module type'),
queryset=ModuleType.objects.all(), queryset=ModuleType.objects.all(),
required=False required=False,
context={
'parent': 'manufacturer',
}
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -233,7 +233,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='powerfeed', model_name='powerfeed',
name='rack', name='rack',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.rack'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='powerfeeds', to='dcim.rack'),
), ),
migrations.AddField( migrations.AddField(
model_name='powerfeed', model_name='powerfeed',

View File

@ -160,25 +160,26 @@ class Cable(PrimaryModel):
# Validate length and length_unit # Validate length and length_unit
if self.length is not None and not self.length_unit: if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length") raise ValidationError(_("Must specify a unit when setting a cable length"))
if self.pk is None and (not self.a_terminations or not self.b_terminations): if self.pk is None and (not self.a_terminations or not self.b_terminations):
raise ValidationError("Must define A and B terminations when creating a new cable.") raise ValidationError(_("Must define A and B terminations when creating a new cable."))
if self._terminations_modified: if self._terminations_modified:
# Check that all termination objects for either end are of the same type # Check that all termination objects for either end are of the same type
for terms in (self.a_terminations, self.b_terminations): for terms in (self.a_terminations, self.b_terminations):
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]): if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
raise ValidationError("Cannot connect different termination types to same end of cable.") raise ValidationError(_("Cannot connect different termination types to same end of cable."))
# Check that termination types are compatible # Check that termination types are compatible
if self.a_terminations and self.b_terminations: if self.a_terminations and self.b_terminations:
a_type = self.a_terminations[0]._meta.model_name a_type = self.a_terminations[0]._meta.model_name
b_type = self.b_terminations[0]._meta.model_name b_type = self.b_terminations[0]._meta.model_name
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type): if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}") raise ValidationError(
_("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type)
)
if a_type == b_type: if a_type == b_type:
# can't directly use self.a_terminations here as possible they # can't directly use self.a_terminations here as possible they
# don't have pk yet # don't have pk yet
@ -327,17 +328,24 @@ class CableTermination(ChangeLoggedModel):
existing_termination = qs.first() existing_termination = qs.first()
if existing_termination is not None: if existing_termination is not None:
raise ValidationError( raise ValidationError(
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} " _("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}".format(
f"{self.termination_id}: cable {existing_termination.cable.pk}" app_label=self.termination_type.app_label,
model=self.termination_type.model,
termination_id=self.termination_id,
cable_pk=existing_termination.cable.pk
))
) )
# Validate interface type (if applicable) # Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces") raise ValidationError(
_("Cables cannot be terminated to {type_display} interfaces").format(
type_display=self.termination.get_type_display()
)
)
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None: if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
raise ValidationError("Circuit terminations attached to a provider network may not be cabled.") raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
super().clean() super().clean()
# Validate that the parent Device can have DeviceBays # Validate that the parent Device can have DeviceBays
if not self.device.device_type.is_parent_device: if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format( raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
device_type=self.device.device_type device_type=self.device.device_type
)) ))
# Cannot install a device into itself, obviously # Cannot install a device into itself, obviously
if self.device == self.installed_device: if self.installed_device and getattr(self, 'device', None) == self.installed_device:
raise ValidationError(_("Cannot install a device into itself.")) raise ValidationError(_("Cannot install a device into itself."))
# Check that the installed device is not already installed elsewhere # Check that the installed device is not already installed elsewhere

View File

@ -815,20 +815,6 @@ class Device(
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk]) 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): def clean(self):
super().clean() super().clean()
@ -875,7 +861,7 @@ class Device(
if self.position and self.device_type.u_height == 0: if self.position and self.device_type.u_height == 0:
raise ValidationError({ raise ValidationError({
'position': _( 'position': _(
"A U0 device type ({device_type}) cannot be assigned to a rack position." "A 0U device type ({device_type}) cannot be assigned to a rack position."
).format(device_type=self.device_type) ).format(device_type=self.device_type)
}) })

View File

@ -84,6 +84,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
rack = models.ForeignKey( rack = models.ForeignKey(
to='Rack', to='Rack',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='powerfeeds',
blank=True, blank=True,
null=True null=True
) )

View File

@ -359,6 +359,11 @@ class CableTerminationTable(NetBoxTable):
verbose_name=_('Mark Connected'), verbose_name=_('Mark Connected'),
) )
def value_link_peer(self, value):
return ', '.join([
f"{termination.parent_object} > {termination}" for termination in value
])
class PathEndpointTable(CableTerminationTable): class PathEndpointTable(CableTerminationTable):
connection = columns.TemplateColumn( connection = columns.TemplateColumn(

View File

@ -36,7 +36,7 @@ DEVICEBAY_STATUS = """
INTERFACE_IPADDRESSES = """ INTERFACE_IPADDRESSES = """
{% if value.count > 3 %} {% if value.count > 3 %}
<a href="{% url 'ipam:ipaddress_list' %}?interface_id={{ record.pk }}">{{ value.count }}</a> <a href="{% url 'ipam:ipaddress_list' %}?{{ record|meta:"model_name" }}_id={{ record.pk }}">{{ value.count }}</a>
{% else %} {% else %}
{% for ip in value.all %} {% for ip in value.all %}
{% if ip.status != 'active' %} {% if ip.status != 'active' %}

View File

@ -1,6 +1,7 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _
from rest_framework import status from rest_framework import status
from dcim.choices import * from dcim.choices import *
@ -45,7 +46,7 @@ class Mixins:
name='Peer Device' name='Peer Device'
) )
if self.peer_termination_type is None: if self.peer_termination_type is None:
raise NotImplementedError("Test case must set peer_termination_type") raise NotImplementedError(_("Test case must set peer_termination_type"))
peer_obj = self.peer_termination_type.objects.create( peer_obj = self.peer_termination_type.objects.create(
device=peer_device, device=peer_device,
name='Peer Termination' name='Peer Termination'
@ -67,7 +68,7 @@ class Mixins:
class RegionTest(APIViewTestCases.APIViewTestCase): class RegionTest(APIViewTestCases.APIViewTestCase):
model = Region model = Region
brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
create_data = [ create_data = [
{ {
'name': 'Region 4', 'name': 'Region 4',
@ -96,7 +97,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
class SiteGroupTest(APIViewTestCases.APIViewTestCase): class SiteGroupTest(APIViewTestCases.APIViewTestCase):
model = SiteGroup model = SiteGroup
brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
create_data = [ create_data = [
{ {
'name': 'Site Group 4', 'name': 'Site Group 4',
@ -125,7 +126,7 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase):
class SiteTest(APIViewTestCases.APIViewTestCase): class SiteTest(APIViewTestCases.APIViewTestCase):
model = Site model = Site
brief_fields = ['display', 'id', 'name', 'slug', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
bulk_update_data = { bulk_update_data = {
'status': 'planned', 'status': 'planned',
} }
@ -187,7 +188,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
class LocationTest(APIViewTestCases.APIViewTestCase): class LocationTest(APIViewTestCases.APIViewTestCase):
model = Location model = Location
brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url'] brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -237,7 +238,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
class RackRoleTest(APIViewTestCases.APIViewTestCase): class RackRoleTest(APIViewTestCases.APIViewTestCase):
model = RackRole model = RackRole
brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
create_data = [ create_data = [
{ {
'name': 'Rack Role 4', 'name': 'Rack Role 4',
@ -272,7 +273,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
class RackTest(APIViewTestCases.APIViewTestCase): class RackTest(APIViewTestCases.APIViewTestCase):
model = Rack model = Rack
brief_fields = ['device_count', 'display', 'id', 'name', 'url'] brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'status': 'planned', 'status': 'planned',
} }
@ -360,7 +361,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
class RackReservationTest(APIViewTestCases.APIViewTestCase): class RackReservationTest(APIViewTestCases.APIViewTestCase):
model = RackReservation model = RackReservation
brief_fields = ['display', 'id', 'units', 'url', 'user'] brief_fields = ['description', 'display', 'id', 'units', 'url', 'user']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -407,7 +408,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
class ManufacturerTest(APIViewTestCases.APIViewTestCase): class ManufacturerTest(APIViewTestCases.APIViewTestCase):
model = Manufacturer model = Manufacturer
brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url'] brief_fields = ['description', 'devicetype_count', 'display', 'id', 'name', 'slug', 'url']
create_data = [ create_data = [
{ {
'name': 'Manufacturer 4', 'name': 'Manufacturer 4',
@ -439,7 +440,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
model = DeviceType model = DeviceType
brief_fields = ['device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url'] brief_fields = ['description', 'device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
bulk_update_data = { bulk_update_data = {
'part_number': 'ABC123', 'part_number': 'ABC123',
} }
@ -484,7 +485,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
class ModuleTypeTest(APIViewTestCases.APIViewTestCase): class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
model = ModuleType model = ModuleType
brief_fields = ['display', 'id', 'manufacturer', 'model', 'url'] brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url']
bulk_update_data = { bulk_update_data = {
'part_number': 'ABC123', 'part_number': 'ABC123',
} }
@ -523,7 +524,7 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConsolePortTemplate model = ConsolePortTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -567,7 +568,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConsoleServerPortTemplate model = ConsoleServerPortTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -611,7 +612,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = PowerPortTemplate model = PowerPortTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -655,7 +656,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
model = PowerOutletTemplate model = PowerOutletTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -712,7 +713,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
model = InterfaceTemplate model = InterfaceTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -760,7 +761,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = FrontPortTemplate model = FrontPortTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -849,7 +850,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = RearPortTemplate model = RearPortTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -897,7 +898,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase): class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = ModuleBayTemplate model = ModuleBayTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -937,7 +938,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = DeviceBayTemplate model = DeviceBayTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -977,7 +978,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase): class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
model = InventoryItemTemplate model = InventoryItemTemplate
brief_fields = ['_depth', 'display', 'id', 'name', 'url'] brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1028,7 +1029,7 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceRoleTest(APIViewTestCases.APIViewTestCase): class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
model = DeviceRole model = DeviceRole
brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
create_data = [ create_data = [
{ {
'name': 'Device Role 4', 'name': 'Device Role 4',
@ -1063,7 +1064,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
class PlatformTest(APIViewTestCases.APIViewTestCase): class PlatformTest(APIViewTestCases.APIViewTestCase):
model = Platform model = Platform
brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
create_data = [ create_data = [
{ {
'name': 'Platform 4', 'name': 'Platform 4',
@ -1095,7 +1096,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
class DeviceTest(APIViewTestCases.APIViewTestCase): class DeviceTest(APIViewTestCases.APIViewTestCase):
model = Device model = Device
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'status': 'failed', 'status': 'failed',
} }
@ -1285,7 +1286,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
class ModuleTest(APIViewTestCases.APIViewTestCase): class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module model = Module
brief_fields = ['device', 'display', 'id', 'module_bay', 'module_type', 'url'] brief_fields = ['description', 'device', 'display', 'id', 'module_bay', 'module_type', 'url']
bulk_update_data = { bulk_update_data = {
'serial': '1234ABCD', 'serial': '1234ABCD',
} }
@ -1349,7 +1350,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsolePort model = ConsolePort
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1391,7 +1392,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsoleServerPort model = ConsoleServerPort
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1433,7 +1434,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerPort model = PowerPort
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1472,7 +1473,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerOutlet model = PowerOutlet
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1520,7 +1521,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = Interface model = Interface
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1654,7 +1655,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
class FrontPortTest(APIViewTestCases.APIViewTestCase): class FrontPortTest(APIViewTestCases.APIViewTestCase):
model = FrontPort model = FrontPort
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1712,7 +1713,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
class RearPortTest(APIViewTestCases.APIViewTestCase): class RearPortTest(APIViewTestCases.APIViewTestCase):
model = RearPort model = RearPort
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1754,7 +1755,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTest(APIViewTestCases.APIViewTestCase): class ModuleBayTest(APIViewTestCases.APIViewTestCase):
model = ModuleBay model = ModuleBay
brief_fields = ['display', 'id', 'module', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'installed_module', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1793,7 +1794,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTest(APIViewTestCases.APIViewTestCase): class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay model = DeviceBay
brief_fields = ['device', 'display', 'id', 'name', 'url'] brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1856,7 +1857,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
class InventoryItemTest(APIViewTestCases.APIViewTestCase): class InventoryItemTest(APIViewTestCases.APIViewTestCase):
model = InventoryItem model = InventoryItem
brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url'] brief_fields = ['_depth', 'description', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1916,7 +1917,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase): class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
model = InventoryItemRole model = InventoryItemRole
brief_fields = ['display', 'id', 'inventoryitem_count', 'name', 'slug', 'url'] brief_fields = ['description', 'display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
create_data = [ create_data = [
{ {
'name': 'Inventory Item Role 4', 'name': 'Inventory Item Role 4',
@ -1951,7 +1952,7 @@ class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
class CableTest(APIViewTestCases.APIViewTestCase): class CableTest(APIViewTestCases.APIViewTestCase):
model = Cable model = Cable
brief_fields = ['display', 'id', 'label', 'url'] brief_fields = ['description', 'display', 'id', 'label', 'url']
bulk_update_data = { bulk_update_data = {
'length': 100, 'length': 100,
'length_unit': 'm', 'length_unit': 'm',
@ -2074,7 +2075,7 @@ class ConnectedDeviceTest(APITestCase):
class VirtualChassisTest(APIViewTestCases.APIViewTestCase): class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
model = VirtualChassis model = VirtualChassis
brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'master', 'member_count', 'name', 'url']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -2155,7 +2156,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
class PowerPanelTest(APIViewTestCases.APIViewTestCase): class PowerPanelTest(APIViewTestCases.APIViewTestCase):
model = PowerPanel model = PowerPanel
brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'powerfeed_count', 'url']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -2204,7 +2205,7 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
class PowerFeedTest(APIViewTestCases.APIViewTestCase): class PowerFeedTest(APIViewTestCases.APIViewTestCase):
model = PowerFeed model = PowerFeed
brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url'] brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'status': 'planned', 'status': 'planned',
} }
@ -2259,7 +2260,7 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase): class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
model = VirtualDeviceContext model = VirtualDeviceContext
brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url'] brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'status': 'planned', 'status': 'planned',
} }

View File

@ -2156,7 +2156,7 @@ class CablePathTestCase(TestCase):
device = Device.objects.create( device = Device.objects.create(
site=self.site, site=self.site,
device_type=self.device.device_type, device_type=self.device.device_type,
device_role=self.device.device_role, role=self.device.role,
name='Test mid-span Device' name='Test mid-span Device'
) )
interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface1 = Interface.objects.create(device=self.device, name='Interface 1')

View File

@ -1787,6 +1787,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='foobar1'), Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='foobar1'),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='foobar2'), Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='foobar2'),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'), Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
Platform(name='Platform 4', slug='platform-4'),
) )
Platform.objects.bulk_create(platforms) Platform.objects.bulk_create(platforms)
@ -1813,6 +1814,17 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_available_for_device_type(self):
manufacturers = Manufacturer.objects.all()[:2]
device_type = DeviceType.objects.create(
manufacturer=manufacturers[0],
model='Device Type 1',
slug='device-type-1',
u_height=1
)
params = {'available_for_device_type': device_type.pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Device.objects.all() queryset = Device.objects.all()

View File

@ -533,30 +533,6 @@ class DeviceTestCase(TestCase):
device2.full_clean() device2.full_clean()
device2.save() 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): class CableTestCase(TestCase):

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType 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.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from rest_framework.fields import Field from rest_framework.fields import Field
@ -88,7 +89,7 @@ class CustomFieldsDataField(Field):
if serializer.is_valid(): if serializer.is_valid():
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id'] data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
else: else:
raise ValidationError(f"Unknown related object(s): {data[cf.name]}") raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
# If updating an existing instance, start with existing custom_field_data # If updating an existing instance, start with existing custom_field_data
if self.parent.instance: if self.parent.instance:

View File

@ -1,9 +1,9 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from rest_framework.fields import ListField
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
from core.api.serializers import JobSerializer from core.api.serializers import JobSerializer
@ -16,7 +16,7 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from netbox.api.exceptions import SerializerNotFound from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
from netbox.api.serializers.features import TaggableModelSerializer from netbox.api.serializers.features import TaggableModelSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
@ -44,9 +44,6 @@ __all__ = (
'ImageAttachmentSerializer', 'ImageAttachmentSerializer',
'JournalEntrySerializer', 'JournalEntrySerializer',
'ObjectChangeSerializer', 'ObjectChangeSerializer',
'ReportDetailSerializer',
'ReportSerializer',
'ReportInputSerializer',
'SavedFilterSerializer', 'SavedFilterSerializer',
'ScriptDetailSerializer', 'ScriptDetailSerializer',
'ScriptInputSerializer', 'ScriptInputSerializer',
@ -79,15 +76,16 @@ class EventRuleSerializer(NetBoxModelSerializer):
'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', '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', 'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
@extend_schema_field(OpenApiTypes.OBJECT) @extend_schema_field(OpenApiTypes.OBJECT)
def get_action_object(self, instance): def get_action_object(self, instance):
context = {'request': self.context['request']} context = {'request': self.context['request']}
# We need to manually instantiate the serializer for scripts # We need to manually instantiate the serializer for scripts
if instance.action_type == EventRuleActionChoices.SCRIPT: if instance.action_type == EventRuleActionChoices.SCRIPT:
script_name = instance.action_parameters['script_name'] script = instance.action_object
script = instance.action_object.scripts[script_name]() instance = script.python_class() if script.python_class else None
return NestedScriptSerializer(script, context=context).data return NestedScriptSerializer(instance, context=context).data
else: else:
serializer = get_serializer_for_model( serializer = get_serializer_for_model(
model=instance.action_object_type.model_class(), model=instance.action_object_type.model_class(),
@ -110,6 +108,7 @@ class WebhookSerializer(NetBoxModelSerializer):
'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields', 'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields',
'tags', 'created', 'last_updated', 'tags', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -145,10 +144,11 @@ class CustomFieldSerializer(ValidatedModelSerializer):
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
def validate_type(self, value): def validate_type(self, value):
if self.instance and self.instance.type != value: if self.instance and self.instance.type != value:
raise serializers.ValidationError('Changing the type of custom fields is not supported.') raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
return value return value
@ -187,6 +187,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
'choices_count', 'created', 'last_updated', 'choices_count', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')
# #
@ -206,6 +207,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', 'created', 'last_updated', 'button_class', 'new_window', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name')
# #
@ -232,6 +234,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -251,6 +254,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled', 'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
'shared', 'parameters', 'created', 'last_updated', 'shared', 'parameters', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
# #
@ -270,6 +274,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', '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)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_object(self, instance): def get_object(self, instance):
@ -288,7 +293,9 @@ class TagSerializer(ValidatedModelSerializer):
many=True, many=True,
required=False required=False
) )
tagged_items = serializers.IntegerField(read_only=True)
# Related object counts
tagged_items = RelatedObjectCountField('extras_taggeditem_items')
class Meta: class Meta:
model = Tag model = Tag
@ -296,6 +303,7 @@ class TagSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created', 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')
# #
@ -315,6 +323,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
'image_width', 'created', 'last_updated', 'image_width', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'image')
def validate(self, data): def validate(self, data):
@ -364,6 +373,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created', 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated', 'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'created')
def validate(self, data): def validate(self, data):
@ -487,6 +497,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -508,81 +519,58 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source', 'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated', 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
#
# Reports
#
class ReportSerializer(serializers.Serializer):
url = serializers.HyperlinkedIdentityField(
view_name='extras-api:report-detail',
lookup_field='full_name',
lookup_url_kwarg='pk'
)
id = serializers.CharField(read_only=True, source="full_name")
module = serializers.CharField(max_length=255)
name = serializers.CharField(max_length=255)
description = serializers.CharField(max_length=255, required=False)
test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
result = NestedJobSerializer()
display = serializers.SerializerMethodField(read_only=True)
@extend_schema_field(serializers.CharField())
def get_display(self, obj):
return f'{obj.name} ({obj.module})'
class ReportDetailSerializer(ReportSerializer):
result = JobSerializer()
class ReportInputSerializer(serializers.Serializer):
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)
def validate_schedule_at(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this report.")
return value
def validate_interval(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this report.")
return value
# #
# Scripts # Scripts
# #
class ScriptSerializer(serializers.Serializer): class ScriptSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField( url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
view_name='extras-api:script-detail', description = serializers.SerializerMethodField(read_only=True)
lookup_field='full_name',
lookup_url_kwarg='pk'
)
id = serializers.CharField(read_only=True, source="full_name")
module = serializers.CharField(max_length=255)
name = serializers.CharField(read_only=True)
description = serializers.CharField(read_only=True)
vars = serializers.SerializerMethodField(read_only=True) vars = serializers.SerializerMethodField(read_only=True)
result = NestedJobSerializer() result = NestedJobSerializer(read_only=True)
display = serializers.SerializerMethodField(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)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_vars(self, instance): def get_vars(self, obj):
if obj.python_class:
return { return {
k: v.__class__.__name__ for k, v in instance._get_vars().items() k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
} }
else:
return {}
@extend_schema_field(serializers.CharField()) @extend_schema_field(serializers.CharField())
def get_display(self, obj): def get_display(self, obj):
return f'{obj.name} ({obj.module})' 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): class ScriptDetailSerializer(ScriptSerializer):
result = JobSerializer() result = serializers.SerializerMethodField(read_only=True)
@extend_schema_field(JobSerializer())
def get_result(self, obj):
job = obj.jobs.all().order_by('-created').first()
context = {
'request': self.context['request']
}
data = JobSerializer(job, context=context).data
return data
class ScriptInputSerializer(serializers.Serializer): class ScriptInputSerializer(serializers.Serializer):
@ -593,12 +581,12 @@ class ScriptInputSerializer(serializers.Serializer):
def validate_schedule_at(self, value): def validate_schedule_at(self, value):
if value and not self.context['script'].scheduling_enabled: if value and not self.context['script'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this script.") raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value return value
def validate_interval(self, value): def validate_interval(self, value):
if value and not self.context['script'].scheduling_enabled: if value and not self.context['script'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this script.") raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value return value

View File

@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
@ -9,21 +8,20 @@ from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from rq import Worker from rq import Worker
from core.choices import JobStatusChoices
from core.models import Job from core.models import Job
from extras import filtersets from extras import filtersets
from extras.models import * from extras.models import *
from extras.scripts import get_module_and_script, run_script from extras.scripts import run_script
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.renderers import TextRenderer from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException from utilities.exceptions import RQWorkerNotRunningException
from utilities.utils import copy_safe_request, count_related from utilities.utils import copy_safe_request
from . import serializers from . import serializers
from .mixins import ConfigTemplateRenderMixin from .mixins import ConfigTemplateRenderMixin
@ -115,7 +113,7 @@ class CustomLinkViewSet(NetBoxModelViewSet):
class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet): class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
metadata_class = ContentTypeMetadata metadata_class = ContentTypeMetadata
queryset = ExportTemplate.objects.prefetch_related('data_source', 'data_file') queryset = ExportTemplate.objects.all()
serializer_class = serializers.ExportTemplateSerializer serializer_class = serializers.ExportTemplateSerializer
filterset_class = filtersets.ExportTemplateFilterSet filterset_class = filtersets.ExportTemplateFilterSet
@ -147,9 +145,7 @@ class BookmarkViewSet(NetBoxModelViewSet):
# #
class TagViewSet(NetBoxModelViewSet): class TagViewSet(NetBoxModelViewSet):
queryset = Tag.objects.annotate( queryset = Tag.objects.all()
tagged_items=count_related(TaggedItem, 'tag')
)
serializer_class = serializers.TagSerializer serializer_class = serializers.TagSerializer
filterset_class = filtersets.TagFilterSet filterset_class = filtersets.TagFilterSet
@ -181,10 +177,7 @@ class JournalEntryViewSet(NetBoxModelViewSet):
# #
class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet): class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
queryset = ConfigContext.objects.prefetch_related( queryset = ConfigContext.objects.all()
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', 'data_source',
'data_file',
)
serializer_class = serializers.ConfigContextSerializer serializer_class = serializers.ConfigContextSerializer
filterset_class = filtersets.ConfigContextFilterSet filterset_class = filtersets.ConfigContextFilterSet
@ -194,7 +187,7 @@ class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
# #
class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet): class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file') queryset = ConfigTemplate.objects.all()
serializer_class = serializers.ConfigTemplateSerializer serializer_class = serializers.ConfigTemplateSerializer
filterset_class = filtersets.ConfigTemplateFilterSet filterset_class = filtersets.ConfigTemplateFilterSet
@ -214,66 +207,30 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
# Scripts # Scripts
# #
class ScriptViewSet(ViewSet): class ScriptViewSet(ModelViewSet):
permission_classes = [IsAuthenticatedOrLoginNotRequired] permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = Script.objects.prefetch_related('jobs')
serializer_class = serializers.ScriptSerializer
filterset_class = filtersets.ScriptFilterSet
_ignore_model_permissions = True _ignore_model_permissions = True
schema = None
lookup_value_regex = '[^/]+' # Allow dots lookup_value_regex = '[^/]+' # Allow dots
def _get_script(self, pk):
try:
module_name, script_name = pk.split('.', maxsplit=1)
except ValueError:
raise Http404
module, script = get_module_and_script(module_name, script_name)
if script is None:
raise Http404
return module, script
def list(self, request):
results = {
job.name: job
for job in Job.objects.filter(
object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).order_by('name', '-created').distinct('name').defer('data')
}
script_list = []
for script_module in ScriptModule.objects.restrict(request.user):
script_list.extend(script_module.scripts.values())
# Attach Job objects to each script (if any)
for script in script_list:
script.result = results.get(script.class_name, None)
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
return Response({'count': len(script_list), 'results': serializer.data})
def retrieve(self, request, pk): def retrieve(self, request, pk):
module, script = self._get_script(pk) script = get_object_or_404(self.queryset, pk=pk)
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
script.result = Job.objects.filter(
object_type=object_type,
name=script.class_name,
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first()
serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
return Response(serializer.data) return Response(serializer.data)
def post(self, request, pk): def post(self, request, pk):
""" """
Run a Script identified as "<module>.<script>" and return the pending Job as the result Run a Script identified by the id and return the pending Job as the result
""" """
if not request.user.has_perm('extras.run_script'): if not request.user.has_perm('extras.run_script'):
raise PermissionDenied("This user does not have permission to run scripts.") raise PermissionDenied("This user does not have permission to run scripts.")
module, script = self._get_script(pk) script = get_object_or_404(self.queryset, pk=pk)
input_serializer = serializers.ScriptInputSerializer( input_serializer = serializers.ScriptInputSerializer(
data=request.data, data=request.data,
context={'script': script} context={'script': script}
@ -286,13 +243,13 @@ class ScriptViewSet(ViewSet):
if input_serializer.is_valid(): if input_serializer.is_valid():
script.result = Job.enqueue( script.result = Job.enqueue(
run_script, run_script,
instance=module, instance=script.module,
name=script.class_name, name=script.python_class.class_name,
user=request.user, user=request.user,
data=input_serializer.data['data'], data=input_serializer.data['data'],
request=copy_safe_request(request), request=copy_safe_request(request),
commit=input_serializer.data['commit'], commit=input_serializer.data['commit'],
job_timeout=script.job_timeout, job_timeout=script.python_class.job_timeout,
schedule_at=input_serializer.validated_data.get('schedule_at'), schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval') interval=input_serializer.validated_data.get('interval')
) )
@ -312,7 +269,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
Retrieve a list of recent changes. Retrieve a list of recent changes.
""" """
metadata_class = ContentTypeMetadata metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.valid_models().prefetch_related('user') queryset = ObjectChange.objects.valid_models()
serializer_class = serializers.ObjectChangeSerializer serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet filterset_class = filtersets.ObjectChangeFilterSet

View File

@ -5,4 +5,8 @@ class ExtrasConfig(AppConfig):
name = "extras" name = "extras"
def ready(self): def ready(self):
from netbox.models.features import register_models
from . import dashboard, lookups, search, signals from . import dashboard, lookups, search, signals
# Register models
register_models(*self.get_models())

View File

@ -1,5 +1,6 @@
import functools import functools
import re import re
from django.utils.translation import gettext as _
__all__ = ( __all__ = (
'Condition', 'Condition',
@ -50,11 +51,13 @@ class Condition:
def __init__(self, attr, value, op=EQ, negate=False): def __init__(self, attr, value, op=EQ, negate=False):
if op not in self.OPERATORS: if op not in self.OPERATORS:
raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}") raise ValueError(_("Unknown operator: {op}. Must be one of: {operators}").format(
op=op, operators=', '.join(self.OPERATORS)
))
if type(value) not in self.TYPES: if type(value) not in self.TYPES:
raise ValueError(f"Unsupported value type: {type(value)}") raise ValueError(_("Unsupported value type: {value}").format(value=type(value)))
if op not in self.TYPES[type(value)]: if op not in self.TYPES[type(value)]:
raise ValueError(f"Invalid type for {op} operation: {type(value)}") raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value)))
self.attr = attr self.attr = attr
self.value = value self.value = value
@ -131,14 +134,17 @@ class ConditionSet:
""" """
def __init__(self, ruleset): def __init__(self, ruleset):
if type(ruleset) is not dict: if type(ruleset) is not dict:
raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.") raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
if len(ruleset) != 1: if len(ruleset) != 1:
raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})") raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
ruleset=len(ruleset)))
# Determine the logic type # Determine the logic type
logic = list(ruleset.keys())[0] logic = list(ruleset.keys())[0]
if type(logic) is not str or logic.lower() not in (AND, OR): if type(logic) is not str or logic.lower() not in (AND, OR):
raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')") raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
logic=logic, op_and=AND, op_or=OR
))
self.logic = logic.lower() self.logic = logic.lower()
# Compile the set of Conditions # Compile the set of Conditions

View File

@ -2,6 +2,7 @@ import uuid
from django.conf import settings from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext as _
from netbox.registry import registry from netbox.registry import registry
from extras.constants import DEFAULT_DASHBOARD from extras.constants import DEFAULT_DASHBOARD
@ -32,7 +33,7 @@ def get_widget_class(name):
try: try:
return registry['widgets'][name] return registry['widgets'][name]
except KeyError: except KeyError:
raise ValueError(f"Unregistered widget class: {name}") raise ValueError(_("Unregistered widget class: {name}").format(name=name))
def get_dashboard(user): def get_dashboard(user):

View File

@ -111,7 +111,9 @@ class DashboardWidget:
Params: Params:
request: The current request request: The current request
""" """
raise NotImplementedError(f"{self.__class__} must define a render() method.") raise NotImplementedError(_("{class_name} must define a render() method.").format(
class_name=self.__class__
))
@property @property
def name(self): def name(self):
@ -177,7 +179,7 @@ class ObjectCountsWidget(DashboardWidget):
try: try:
dict(data) dict(data)
except TypeError: except TypeError:
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.") raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary."))
return data return data
def render(self, request): def render(self, request):
@ -231,7 +233,7 @@ class ObjectListWidget(DashboardWidget):
try: try:
urlencode(data) urlencode(data)
except (TypeError, ValueError): except (TypeError, ValueError):
raise forms.ValidationError("Invalid format. URL parameters must be passed as a dictionary.") raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
return data return data
def render(self, request): def render(self, request):

View File

@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone from django.utils import timezone
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from django_rq import get_queue from django_rq import get_queue
from core.models import Job from core.models import Job
@ -115,21 +116,21 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
# Scripts # Scripts
elif event_rule.action_type == EventRuleActionChoices.SCRIPT: elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
# Resolve the script from action parameters # Resolve the script from action parameters
script_module = event_rule.action_object script = event_rule.action_object.python_class()
script_name = event_rule.action_parameters['script_name']
script = script_module.scripts[script_name]()
# Enqueue a Job to record the script's execution # Enqueue a Job to record the script's execution
Job.enqueue( Job.enqueue(
"extras.scripts.run_script", "extras.scripts.run_script",
instance=script_module, instance=script.module,
name=script.class_name, name=script.name,
user=user, user=user,
data=data data=data
) )
else: else:
raise ValueError(f"Unknown action type for an event rule: {event_rule.action_type}") raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
action_type=event_rule.action_type
))
def process_event_queue(events): def process_event_queue(events):
@ -175,4 +176,4 @@ def flush_events(queue):
func = import_string(name) func = import_string(name)
func(queue) func(queue)
except Exception as e: except Exception as e:
logger.error(f"Cannot import events pipeline {name} error: {e}") logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))

View File

@ -29,11 +29,32 @@ __all__ = (
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
'SavedFilterFilterSet', 'SavedFilterFilterSet',
'ScriptFilterSet',
'TagFilterSet', 'TagFilterSet',
'WebhookFilterSet', 'WebhookFilterSet',
) )
class ScriptFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
class Meta:
model = Script
fields = [
'id', 'name',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
)
class WebhookFilterSet(NetBoxModelFilterSet): class WebhookFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -202,7 +202,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
try: try:
webhook = Webhook.objects.get(name=action_object) webhook = Webhook.objects.get(name=action_object)
except Webhook.DoesNotExist: except Webhook.DoesNotExist:
raise forms.ValidationError(f"Webhook {action_object} not found") raise forms.ValidationError(_("Webhook {name} not found").format(name=action_object))
self.instance.action_object = webhook self.instance.action_object = webhook
# Script # Script
elif action_type == EventRuleActionChoices.SCRIPT: elif action_type == EventRuleActionChoices.SCRIPT:
@ -211,12 +211,9 @@ class EventRuleImportForm(NetBoxModelImportForm):
try: try:
module, script = get_module_and_script(module_name, script_name) module, script = get_module_and_script(module_name, script_name)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise forms.ValidationError(f"Script {action_object} not found") raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
self.instance.action_object = module self.instance.action_object = script
self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False) self.instance.action_object_type = ContentType.objects.get_for_model(script, for_concrete_model=False)
self.instance.action_parameters = {
'script_name': script_name,
}
class TagImportForm(CSVModelForm): class TagImportForm(CSVModelForm):

View File

@ -297,20 +297,16 @@ class EventRuleForm(NetBoxModelForm):
} }
def init_script_choice(self): def init_script_choice(self):
choices = [] initial = None
for module in ScriptModule.objects.all(): if self.instance.action_type == EventRuleActionChoices.SCRIPT:
scripts = [] script_id = get_field_value(self, 'action_object_id')
for script_name in module.scripts.keys(): initial = Script.objects.get(pk=script_id) if script_id else None
name = f"{str(module.pk)}:{script_name}" self.fields['action_choice'] = DynamicModelChoiceField(
scripts.append((name, script_name)) label=_('Script'),
if scripts: queryset=Script.objects.all(),
choices.append((str(module), scripts)) required=True,
self.fields['action_choice'].choices = choices initial=initial
)
if self.instance.action_type == EventRuleActionChoices.SCRIPT and self.instance.action_parameters:
scriptmodule_id = self.instance.action_object_id
script_name = self.instance.action_parameters.get('script_name')
self.fields['action_choice'].initial = f'{scriptmodule_id}:{script_name}'
def init_webhook_choice(self): def init_webhook_choice(self):
initial = None initial = None
@ -348,26 +344,13 @@ class EventRuleForm(NetBoxModelForm):
# Script # Script
elif self.cleaned_data.get('action_type') == EventRuleActionChoices.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'] = ContentType.objects.get_for_model(
ScriptModule, Script,
for_concrete_model=False for_concrete_model=False
) )
module_id, script_name = action_choice.split(":", maxsplit=1) self.cleaned_data['action_object_id'] = action_choice.id
self.cleaned_data['action_object_id'] = module_id
return self.cleaned_data return self.cleaned_data
def save(self, *args, **kwargs):
# Set action_parameters on the instance
if self.cleaned_data['action_type'] == EventRuleActionChoices.SCRIPT:
module_id, script_name = self.cleaned_data.get('action_choice').split(":", maxsplit=1)
self.instance.action_parameters = {
'script_name': script_name,
}
else:
self.instance.action_parameters = None
return super().save(*args, **kwargs)
class TagForm(forms.ModelForm): class TagForm(forms.ModelForm):
slug = SlugField() slug = SlugField()

View File

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.utils.translation import gettext as _
from netbox.registry import registry from netbox.registry import registry
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
@ -62,7 +63,7 @@ class Command(BaseCommand):
# Determine which models to reindex # Determine which models to reindex
indexers = self._get_indexers(*model_labels) indexers = self._get_indexers(*model_labels)
if not indexers: if not indexers:
raise CommandError("No indexers found!") raise CommandError(_("No indexers found!"))
self.stdout.write(f'Reindexing {len(indexers)} models.') self.stdout.write(f'Reindexing {len(indexers)} models.')
# Clear all cached values for the specified models (if not being lazy) # Clear all cached values for the specified models (if not being lazy)

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.9 on 2024-02-20 17:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0106_bookmark_user_cascade_deletion'),
]
operations = [
migrations.AddIndex(
model_name='cachedvalue',
index=models.Index(fields=['object_type', 'object_id'], name='extras_cachedvalue_object'),
),
]

View File

@ -14,7 +14,7 @@ def convert_reportmodule_jobs(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('extras', '0106_bookmark_user_cascade_deletion'), ('extras', '0107_cachedvalue_extras_cachedvalue_object'),
] ]
operations = [ operations = [

View File

@ -0,0 +1,159 @@
import inspect
import os
from importlib.machinery import SourceFileLoader
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
#
# Note: This has a couple dependencies on the codebase if doing future modifications:
# There are imports from extras.scripts and extras.reports as well as expecting
# settings.SCRIPTS_ROOT and settings.REPORTS_ROOT to be in settings
#
ROOT_PATHS = {
'scripts': settings.SCRIPTS_ROOT,
'reports': settings.REPORTS_ROOT,
}
def get_full_path(scriptmodule):
"""
Return the full path to a ScriptModule's file on disk.
"""
root_path = ROOT_PATHS[scriptmodule.file_root]
return os.path.join(root_path, scriptmodule.file_path)
def get_python_name(scriptmodule):
"""
Return the Python name of a ScriptModule's file on disk.
"""
path, filename = os.path.split(scriptmodule.file_path)
return os.path.splitext(filename)[0]
def is_script(obj):
"""
Returns True if the passed Python object is a Script or Report.
"""
from extras.scripts import Script
from extras.reports import Report
try:
if issubclass(obj, Report) and obj != Report:
return True
if issubclass(obj, Script) and obj != Script:
return True
except TypeError:
pass
return False
def get_module_scripts(scriptmodule):
"""
Return a dictionary mapping of name and script class inside the passed ScriptModule.
"""
def get_name(cls):
# For child objects in submodules use the full import path w/o the root module as the name
return cls.full_name.split(".", maxsplit=1)[1]
loader = SourceFileLoader(get_python_name(scriptmodule), get_full_path(scriptmodule))
module = loader.load_module()
scripts = {}
ordered = getattr(module, 'script_order', [])
for cls in ordered:
scripts[get_name(cls)] = cls
for name, cls in inspect.getmembers(module, is_script):
if cls not in ordered:
scripts[get_name(cls)] = cls
return scripts
def update_scripts(apps, schema_editor):
"""
Create a new Script object for each script inside each existing ScriptModule, and update any related jobs to
reference the new Script object.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
Script = apps.get_model('extras', 'Script')
ScriptModule = apps.get_model('extras', 'ScriptModule')
Job = apps.get_model('core', 'Job')
script_ct = ContentType.objects.get_for_model(Script)
scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule)
for module in ScriptModule.objects.all():
for script_name in get_module_scripts(module):
script = Script.objects.create(
name=script_name,
module=module,
)
# Update all Jobs associated with this ScriptModule & script name to point to the new Script object
Job.objects.filter(
object_type=scriptmodule_ct,
object_id=module.pk,
name=script_name
).update(object_type=script_ct, object_id=script.pk)
def update_event_rules(apps, schema_editor):
"""
Update any existing EventRules for scripts. Change action_object_type from ScriptModule to Script, and populate
the ID of the related Script object.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
Script = apps.get_model('extras', 'Script')
ScriptModule = apps.get_model('extras', 'ScriptModule')
EventRule = apps.get_model('extras', 'EventRule')
script_ct = ContentType.objects.get_for_model(Script)
scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule)
for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct):
name = eventrule.action_parameters.get('script_name')
obj, created = Script.objects.get_or_create(
module_id=eventrule.action_object_id,
name=name,
defaults={'is_executable': False}
)
EventRule.objects.filter(pk=eventrule.pk).update(action_object_type=script_ct, action_object_id=obj.id)
class Migration(migrations.Migration):
dependencies = [
('extras', '0108_convert_reports_to_scripts'),
]
operations = [
migrations.CreateModel(
name='Script',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(editable=False, max_length=79)),
('module', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='scripts', to='extras.scriptmodule')),
('is_executable', models.BooleanField(editable=False, default=True))
],
options={
'ordering': ('module', 'name'),
},
),
migrations.AddConstraint(
model_name='script',
constraint=models.UniqueConstraint(fields=('name', 'module'), name='extras_script_unique_name_module'),
),
migrations.RunPython(
code=update_scripts,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=update_event_rules,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0109_script_model'),
]
operations = [
migrations.RemoveField(
model_name='eventrule',
name='action_parameters',
),
]

View File

@ -115,10 +115,6 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
ct_field='action_object_type', ct_field='action_object_type',
fk_field='action_object_id' fk_field='action_object_id'
) )
action_parameters = models.JSONField(
blank=True,
null=True
)
action_data = models.JSONField( action_data = models.JSONField(
verbose_name=_('data'), verbose_name=_('data'),
blank=True, blank=True,

View File

@ -2,8 +2,11 @@ import inspect
import logging import logging
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -22,12 +25,63 @@ __all__ = (
logger = logging.getLogger('netbox.data_backends') logger = logging.getLogger('netbox.data_backends')
class Script(EventRulesMixin, models.Model): class Script(EventRulesMixin, JobsMixin):
""" name = models.CharField(
Dummy model used to generate permissions for custom scripts. Does not exist in the database. verbose_name=_('name'),
""" max_length=79, # Maximum length for a Python class name
editable=False,
)
module = models.ForeignKey(
to='extras.ScriptModule',
on_delete=models.CASCADE,
related_name='scripts',
editable=False
)
is_executable = models.BooleanField(
default=True,
verbose_name=_('is executable'),
editable=False
)
events = GenericRelation(
'extras.EventRule',
content_type_field='action_object_type',
object_id_field='action_object_id'
)
def __str__(self):
return self.name
objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
managed = False ordering = ('module', 'name')
constraints = (
models.UniqueConstraint(
fields=('name', 'module'),
name='extras_script_unique_name_module'
),
)
verbose_name = _('script')
verbose_name_plural = _('scripts')
def get_absolute_url(self):
return reverse('extras:script', args=[self.pk])
@property
def result(self):
return self.jobs.all().order_by('-created').first()
@cached_property
def python_class(self):
return self.module.module_scripts.get(self.name)
def delete(self, soft_delete=False, **kwargs):
if soft_delete and self.jobs.exists():
self.is_executable = False
self.save()
else:
super().delete(**kwargs)
self.id = None
class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)): class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
@ -55,7 +109,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
return self.python_name return self.python_name
@cached_property @cached_property
def scripts(self): def module_scripts(self):
def _get_name(cls): def _get_name(cls):
# For child objects in submodules use the full import path w/o the root module as the name # For child objects in submodules use the full import path w/o the root module as the name
@ -78,6 +132,39 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
return scripts return scripts
def sync_classes(self):
"""
Syncs the file-based module to the database, adding and removing individual Script objects
in the database as needed.
"""
db_classes = {
script.name: script for script in self.scripts.all()
}
db_classes_set = set(db_classes.keys())
module_classes_set = set(self.module_scripts.keys())
# remove any existing db classes if they are no longer in the file
removed = db_classes_set - module_classes_set
for name in removed:
db_classes[name].delete(soft_delete=True)
added = module_classes_set - db_classes_set
for name in added:
Script.objects.create(
module=self,
name=name,
is_executable=True,
)
def sync_data(self):
super().sync_data()
self.sync_classes()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.file_root = ManagedFileRootPathChoices.SCRIPTS self.file_root = ManagedFileRootPathChoices.SCRIPTS
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@receiver(post_save, sender=ScriptModule)
def script_module_post_save_handler(instance, created, **kwargs):
instance.sync_classes()

View File

@ -57,6 +57,9 @@ class CachedValue(models.Model):
ordering = ('weight', 'object_type', 'value', 'object_id') ordering = ('weight', 'object_type', 'value', 'object_id')
verbose_name = _('cached value') verbose_name = _('cached value')
verbose_name_plural = _('cached values') verbose_name_plural = _('cached values')
indexes = (
models.Index(fields=('object_type', 'object_id'), name='extras_cachedvalue_object'),
)
def __str__(self): def __str__(self):
return f'{self.object_type} {self.object_id}: {self.field}={self.value}' return f'{self.object_type} {self.object_id}: {self.field}={self.value}'

View File

@ -6,6 +6,7 @@ __all__ = (
) )
# Required by extras/migrations/0109_script_models.py
class Report(BaseScript): class Report(BaseScript):
# #

View File

@ -17,7 +17,7 @@ from django.utils.translation import gettext as _
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.models import Job from core.models import Job
from extras.choices import LogLevelChoices from extras.choices import LogLevelChoices
from extras.models import ScriptModule from extras.models import ScriptModule, Script as ScriptModel
from extras.signals import clear_events from extras.signals import clear_events
from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@ -193,16 +193,19 @@ class ObjectVar(ScriptVariable):
:param model: The NetBox model being referenced :param model: The NetBox model being referenced
:param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional) :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
:param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
elements within the dropdown menu (optional)
:param null_option: The label to use as a "null" selection option (optional) :param null_option: The label to use as a "null" selection option (optional)
""" """
form_field = DynamicModelChoiceField form_field = DynamicModelChoiceField
def __init__(self, model, query_params=None, null_option=None, *args, **kwargs): def __init__(self, model, query_params=None, context=None, null_option=None, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.field_attrs.update({ self.field_attrs.update({
'queryset': model.objects.all(), 'queryset': model.objects.all(),
'query_params': query_params, 'query_params': query_params,
'context': context,
'null_option': null_option, 'null_option': null_option,
}) })
@ -408,11 +411,11 @@ class BaseScript:
fieldsets.extend(self.fieldsets) fieldsets.extend(self.fieldsets)
else: else:
fields = list(name for name, _ in self._get_vars().items()) fields = list(name for name, _ in self._get_vars().items())
fieldsets.append(('Script Data', fields)) fieldsets.append((_('Script Data'), fields))
# Append the default fieldset if defined in the Meta class # Append the default fieldset if defined in the Meta class
exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',) exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
fieldsets.append(('Script Execution Parameters', exec_parameters)) fieldsets.append((_('Script Execution Parameters'), exec_parameters))
return fieldsets return fieldsets
@ -579,7 +582,7 @@ def is_variable(obj):
def get_module_and_script(module_name, script_name): def get_module_and_script(module_name, script_name):
module = ScriptModule.objects.get(file_path=f'{module_name}.py') module = ScriptModule.objects.get(file_path=f'{module_name}.py')
script = module.scripts.get(script_name) script = module.scripts.get(name=script_name)
return module, script return module, script
@ -596,8 +599,7 @@ def run_script(data, job, request=None, commit=True, **kwargs):
""" """
job.start() job.start()
module = ScriptModule.objects.get(pk=job.object_id) script = ScriptModel.objects.get(pk=job.object_id).python_class()
script = module.scripts.get(job.name)()
logger = logging.getLogger(f"netbox.scripts.{script.full_name}") logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
logger.info(f"Running script (commit={commit})") logger.info(f"Running script (commit={commit})")

View File

@ -1,8 +1,8 @@
import importlib
import logging import logging
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel
from django.db.models.signals import m2m_changed, post_save, pre_delete from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -12,9 +12,10 @@ from core.signals import job_end, job_start
from extras.constants import EVENT_JOB_END, EVENT_JOB_START from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.events import process_event_rules from extras.events import process_event_rules
from extras.models import EventRule from extras.models import EventRule
from extras.validators import CustomValidator from extras.validators import run_validators
from netbox.config import get_config from netbox.config import get_config
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices from .choices import ObjectChangeActionChoices
@ -68,7 +69,7 @@ def handle_changed_object(sender, instance, **kwargs):
else: else:
return return
# Create/update an ObejctChange record for this change # Create/update an ObjectChange record for this change
objectchange = instance.to_objectchange(action) objectchange = instance.to_objectchange(action)
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded # If this is a many-to-many field change, check for a previous ObjectChange instance recorded
# for this object by this request and update it # for this object by this request and update it
@ -108,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
""" """
Fires when an object is deleted. Fires when an object is deleted.
""" """
# Run any deletion protection rules for the object. Note that this must occur prior
# to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:
raise AbortRequest(
_("Deletion is prevented by a protection rule: {message}").format(message=e)
)
# Get the current request, or bail if not set # Get the current request, or bail if not set
request = current_request.get() request = current_request.get()
if request is None: if request is None:
@ -122,6 +135,25 @@ def handle_deleted_object(sender, instance, **kwargs):
objectchange.request_id = request.id objectchange.request_id = request.id
objectchange.save() objectchange.save()
# Django does not automatically send an m2m_changed signal for the reverse direction of a
# many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
# trigger one manually. We do this by checking for any reverse M2M relationships on the
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
# the association. This triggers an m2m_changed signal with the `post_remove` action type
# for the forward direction of the relationship, ensuring that the change is recorded.
for relation in instance._meta.related_objects:
if type(relation) is not ManyToManyRel:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
if not issubclass(related_model, ChangeLoggingMixin):
# We only care about triggering the m2m_changed signal for models which support
# change logging
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance)
# Enqueue webhooks # Enqueue webhooks
queue = events_queue.get() queue = events_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
@ -186,45 +218,17 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
# Custom validation # Custom validation
# #
def run_validators(instance, validators):
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
validator(instance)
@receiver(post_clean) @receiver(post_clean)
def run_save_validators(sender, instance, **kwargs): def run_save_validators(sender, instance, **kwargs):
"""
Run any custom validation rules for the model prior to calling save().
"""
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().CUSTOM_VALIDATORS.get(model_name, []) validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
run_validators(instance, validators) run_validators(instance, validators)
@receiver(pre_delete)
def run_delete_validators(sender, instance, **kwargs):
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:
raise AbortRequest(
_("Deletion is prevented by a protection rule: {message}").format(
message=e
)
)
# #
# Tags # Tags
# #

View File

@ -11,7 +11,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.reports import Report from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
User = get_user_model() User = get_user_model()
@ -29,7 +29,7 @@ class AppTest(APITestCase):
class WebhookTest(APIViewTestCases.APIViewTestCase): class WebhookTest(APIViewTestCases.APIViewTestCase):
model = Webhook model = Webhook
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'name': 'Webhook 4', 'name': 'Webhook 4',
@ -71,7 +71,7 @@ class WebhookTest(APIViewTestCases.APIViewTestCase):
class EventRuleTest(APIViewTestCases.APIViewTestCase): class EventRuleTest(APIViewTestCases.APIViewTestCase):
model = EventRule model = EventRule
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'enabled': False, 'enabled': False,
'description': 'New description', 'description': 'New description',
@ -149,7 +149,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
class CustomFieldTest(APIViewTestCases.APIViewTestCase): class CustomFieldTest(APIViewTestCases.APIViewTestCase):
model = CustomField model = CustomField
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'content_types': ['dcim.site'], 'content_types': ['dcim.site'],
@ -201,7 +201,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase): class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
model = CustomFieldChoiceSet model = CustomFieldChoiceSet
brief_fields = ['choices_count', 'display', 'id', 'name', 'url'] brief_fields = ['choices_count', 'description', 'display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'name': 'Choice Set 4', 'name': 'Choice Set 4',
@ -330,7 +330,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
class SavedFilterTest(APIViewTestCases.APIViewTestCase): class SavedFilterTest(APIViewTestCases.APIViewTestCase):
model = SavedFilter model = SavedFilter
brief_fields = ['display', 'id', 'name', 'slug', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
create_data = [ create_data = [
{ {
'content_types': ['dcim.site'], 'content_types': ['dcim.site'],
@ -455,7 +455,7 @@ class BookmarkTest(
class ExportTemplateTest(APIViewTestCases.APIViewTestCase): class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
model = ExportTemplate model = ExportTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'content_types': ['dcim.device'], 'content_types': ['dcim.device'],
@ -500,7 +500,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
class TagTest(APIViewTestCases.APIViewTestCase): class TagTest(APIViewTestCases.APIViewTestCase):
model = Tag model = Tag
brief_fields = ['color', 'display', 'id', 'name', 'slug', 'url'] brief_fields = ['color', 'description', 'display', 'id', 'name', 'slug', 'url']
create_data = [ create_data = [
{ {
'name': 'Tag 4', 'name': 'Tag 4',
@ -627,7 +627,7 @@ class JournalEntryTest(APIViewTestCases.APIViewTestCase):
class ConfigContextTest(APIViewTestCases.APIViewTestCase): class ConfigContextTest(APIViewTestCases.APIViewTestCase):
model = ConfigContext model = ConfigContext
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'name': 'Config Context 4', 'name': 'Config Context 4',
@ -708,7 +708,7 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
class ConfigTemplateTest(APIViewTestCases.APIViewTestCase): class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConfigTemplate model = ConfigTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'name': 'Config Template 4', 'name': 'Config Template 4',
@ -748,7 +748,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
class ScriptTest(APITestCase): class ScriptTest(APITestCase):
class TestScript(Script): class TestScriptClass(PythonClass):
class Meta: class Meta:
name = "Test script" name = "Test script"
@ -767,27 +767,36 @@ class ScriptTest(APITestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
ScriptModule.objects.create( module = ScriptModule.objects.create(
file_root=ManagedFileRootPathChoices.SCRIPTS, file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='/var/tmp/script.py' file_path='/var/tmp/script.py'
) )
Script.objects.create(
module=module,
name="Test script",
is_executable=True,
)
def get_test_script(self, *args): def python_class(self):
return ScriptModule.objects.first(), self.TestScript return self.TestScriptClass
def setUp(self): def setUp(self):
super().setUp() super().setUp()
# Monkey-patch the API viewset's _get_script() method to return our test Script above # Monkey-patch the Script model to return our TestScriptClass above
from extras.api.views import ScriptViewSet from extras.api.views import ScriptViewSet
ScriptViewSet._get_script = self.get_test_script Script.python_class = self.python_class
def test_get_script(self): def test_get_script(self):
module = ScriptModule.objects.get(
url = reverse('extras-api:script-detail', kwargs={'pk': None}) file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='/var/tmp/script.py'
)
script = module.scripts.all().first()
url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
response = self.client.get(url, **self.header) response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.TestScript.Meta.name) self.assertEqual(response.data['name'], self.TestScriptClass.Meta.name)
self.assertEqual(response.data['vars']['var1'], 'StringVar') self.assertEqual(response.data['vars']['var1'], 'StringVar')
self.assertEqual(response.data['vars']['var2'], 'IntegerVar') self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
self.assertEqual(response.data['vars']['var3'], 'BooleanVar') self.assertEqual(response.data['vars']['var3'], 'BooleanVar')

View File

@ -120,10 +120,15 @@ urlpatterns = [
path('scripts/', views.ScriptListView.as_view(), name='script_list'), path('scripts/', views.ScriptListView.as_view(), name='script_list'),
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'), path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'), path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
path('scripts/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))), path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'),
path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'), path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'),
path('scripts/<str:module>/<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'), path('scripts/<int:pk>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'), path('script-modules/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
# Redirects for legacy script URLs
# TODO: Remove in NetBox v4.1
path('scripts/<str:module>/<str:name>/', views.LegacyScriptRedirectView.as_view()),
path('scripts/<str:module>/<str:name>/<path:path>/', views.LegacyScriptRedirectView.as_view()),
# Markdown # Markdown
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"), path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"),

View File

@ -1,7 +1,5 @@
from taggit.managers import _TaggableManager from taggit.managers import _TaggableManager
from netbox.registry import registry
def is_taggable(obj): def is_taggable(obj):
""" """
@ -29,24 +27,6 @@ def image_upload(instance, filename):
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
def register_features(model, features):
"""
Register model features in the application registry.
"""
app_label, model_name = model._meta.label_lower.split('.')
for feature in features:
try:
registry['model_features'][feature][app_label].add(model_name)
except KeyError:
raise KeyError(
f"{feature} is not a valid model feature! Valid keys are: {registry['model_features'].keys()}"
)
# Register public models
if not getattr(model, '_netbox_private', False):
registry['models'][app_label].add(model_name)
def is_script(obj): def is_script(obj):
""" """
Returns True if the object is a Script or Report. Returns True if the object is a Script or Report.

View File

@ -1,3 +1,5 @@
import importlib
from django.core import validators from django.core import validators
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -149,3 +151,21 @@ class CustomValidator:
if field is not None: if field is not None:
raise ValidationError({field: message}) raise ValidationError({field: message})
raise ValidationError(message) raise ValidationError(message)
def run_validators(instance, validators):
"""
Run the provided iterable of validators for the instance.
"""
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
validator(instance)

View File

@ -920,7 +920,7 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
widget = widget_class(**data) widget = widget_class(**data)
request.user.dashboard.add_widget(widget) request.user.dashboard.add_widget(widget)
request.user.dashboard.save() request.user.dashboard.save()
messages.success(request, f'Added widget {widget.id}') messages.success(request, _('Added widget: ') + str(widget.id))
return HttpResponse(headers={ return HttpResponse(headers={
'HX-Redirect': reverse('home'), 'HX-Redirect': reverse('home'),
@ -961,7 +961,7 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View):
data['config'] = config_form.cleaned_data data['config'] = config_form.cleaned_data
request.user.dashboard.config[str(id)].update(data) request.user.dashboard.config[str(id)].update(data)
request.user.dashboard.save() request.user.dashboard.save()
messages.success(request, f'Updated widget {widget.id}') messages.success(request, _('Updated widget: ') + str(widget.id))
return HttpResponse(headers={ return HttpResponse(headers={
'HX-Redirect': reverse('home'), 'HX-Redirect': reverse('home'),
@ -997,9 +997,9 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
if form.is_valid(): if form.is_valid():
request.user.dashboard.delete_widget(id) request.user.dashboard.delete_widget(id)
request.user.dashboard.save() request.user.dashboard.save()
messages.success(request, f'Deleted widget {id}') messages.success(request, _('Deleted widget: ') + str(id))
else: else:
messages.error(request, f'Error deleting widget: {form.errors[0]}') messages.error(request, _('Error deleting widget: ') + str(form.errors[0]))
return redirect(reverse('home')) return redirect(reverse('home'))
@ -1030,7 +1030,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_script' return 'extras.view_script'
def get(self, request): def get(self, request):
script_modules = ScriptModule.objects.restrict(request.user) script_modules = ScriptModule.objects.restrict(request.user).prefetch_related('jobs')
return render(request, 'extras/script_list.html', { return render(request, 'extras/script_list.html', {
'model': ScriptModule, 'model': ScriptModule,
@ -1038,123 +1038,122 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
}) })
def get_script_module(module, request): class ScriptView(generic.ObjectView):
return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.") queryset = Script.objects.all()
def get(self, request, **kwargs):
class ScriptView(ContentTypePermissionRequiredMixin, View): script = self.get_object(**kwargs)
script_class = script.python_class()
def get_required_permission(self): form = script_class.as_form(initial=normalize_querydict(request.GET))
return 'extras.view_script'
def get(self, request, module, name):
module = get_script_module(module, request)
script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
form = script.as_form(initial=normalize_querydict(request.GET))
return render(request, 'extras/script.html', { return render(request, 'extras/script.html', {
'job_count': jobs.count(),
'module': module,
'script': script, 'script': script,
'script_class': script_class,
'form': form, 'form': form,
'job_count': script.jobs.count(),
}) })
def post(self, request, module, name): def post(self, request, **kwargs):
if not request.user.has_perm('extras.run_script'): script = self.get_object(**kwargs)
script_class = script.python_class()
if not request.user.has_perm('extras.run_script', obj=script):
return HttpResponseForbidden() return HttpResponseForbidden()
module = get_script_module(module, request) form = script_class.as_form(request.POST, request.FILES)
script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
form = script.as_form(request.POST, request.FILES)
# Allow execution only if RQ worker process is running # Allow execution only if RQ worker process is running
if not get_workers_for_queue('default'): if not get_workers_for_queue('default'):
messages.error(request, "Unable to run script: RQ worker process not running.") messages.error(request, _("Unable to run script: RQ worker process not running."))
elif form.is_valid(): elif form.is_valid():
job = Job.enqueue( job = Job.enqueue(
run_script, run_script,
instance=module, instance=script,
name=script.class_name, name=script_class.class_name,
user=request.user, user=request.user,
schedule_at=form.cleaned_data.pop('_schedule_at'), schedule_at=form.cleaned_data.pop('_schedule_at'),
interval=form.cleaned_data.pop('_interval'), interval=form.cleaned_data.pop('_interval'),
data=form.cleaned_data, data=form.cleaned_data,
request=copy_safe_request(request), request=copy_safe_request(request),
job_timeout=script.job_timeout, job_timeout=script.python_class.job_timeout,
commit=form.cleaned_data.pop('_commit') commit=form.cleaned_data.pop('_commit')
) )
return redirect('extras:script_result', job_pk=job.pk) return redirect('extras:script_result', job_pk=job.pk)
return render(request, 'extras/script.html', { return render(request, 'extras/script.html', {
'job_count': jobs.count(),
'module': module,
'script': script, 'script': script,
'script_class': script.python_class(),
'form': form, 'form': form,
'job_count': script.jobs.count(),
}) })
class ScriptSourceView(ContentTypePermissionRequiredMixin, View): class ScriptSourceView(generic.ObjectView):
queryset = Script.objects.all()
def get_required_permission(self): def get(self, request, **kwargs):
return 'extras.view_script' script = self.get_object(**kwargs)
def get(self, request, module, name):
module = get_script_module(module, request)
script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
return render(request, 'extras/script/source.html', { return render(request, 'extras/script/source.html', {
'job_count': jobs.count(),
'module': module,
'script': script, 'script': script,
'script_class': script.python_class(),
'job_count': script.jobs.count(),
'tab': 'source', 'tab': 'source',
}) })
class ScriptJobsView(ContentTypePermissionRequiredMixin, View): class ScriptJobsView(generic.ObjectView):
queryset = Script.objects.all()
def get_required_permission(self): def get(self, request, **kwargs):
return 'extras.view_script' script = self.get_object(**kwargs)
def get(self, request, module, name):
module = get_script_module(module, request)
script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
jobs_table = JobTable( jobs_table = JobTable(
data=jobs, data=script.jobs.all(),
orderable=False, orderable=False,
user=request.user user=request.user
) )
jobs_table.configure(request) jobs_table.configure(request)
return render(request, 'extras/script/jobs.html', { return render(request, 'extras/script/jobs.html', {
'job_count': jobs.count(),
'module': module,
'script': script, 'script': script,
'table': jobs_table, 'table': jobs_table,
'job_count': script.jobs.count(),
'tab': 'jobs', 'tab': 'jobs',
}) })
class ScriptResultView(ContentTypePermissionRequiredMixin, View): class LegacyScriptRedirectView(ContentTypePermissionRequiredMixin, View):
"""
Redirect legacy (pre-v4.0) script URLs. Examples:
/extras/scripts/<module>/<name>/ --> /extras/scripts/<id>/
/extras/scripts/<module>/<name>/source/ --> /extras/scripts/<id>/source/
/extras/scripts/<module>/<name>/jobs/ --> /extras/scripts/<id>/jobs/
"""
def get_required_permission(self):
return 'extras.view_script'
def get(self, request, module, name, path=''):
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
script = get_object_or_404(Script.objects.all(), module=module, name=name)
url = reverse('extras:script', kwargs={'pk': script.pk})
return redirect(f'{url}{path}')
class ScriptResultView(generic.ObjectView):
queryset = Job.objects.all()
def get_required_permission(self): def get_required_permission(self):
return 'extras.view_script' return 'extras.view_script'
def get(self, request, job_pk): def get(self, request, **kwargs):
object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='scriptmodule') job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
module = job.object
script = module.scripts[job.name]()
context = { context = {
'script': script, 'script': job.object,
'job': job, 'job': job,
} }
if job.data and 'log' in job.data: if job.data and 'log' in job.data:

View File

@ -2,6 +2,7 @@ from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers from rest_framework import serializers
from ipam import models from ipam import models
from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import WritableNestedSerializer from netbox.api.serializers import WritableNestedSerializer
from .field_serializers import IPAddressField from .field_serializers import IPAddressField
@ -58,7 +59,7 @@ class NestedASNSerializer(WritableNestedSerializer):
) )
class NestedVRFSerializer(WritableNestedSerializer): class NestedVRFSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
prefix_count = serializers.IntegerField(read_only=True) prefix_count = RelatedObjectCountField('prefixes')
class Meta: class Meta:
model = models.VRF model = models.VRF
@ -86,7 +87,7 @@ class NestedRouteTargetSerializer(WritableNestedSerializer):
) )
class NestedRIRSerializer(WritableNestedSerializer): class NestedRIRSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
aggregate_count = serializers.IntegerField(read_only=True) aggregate_count = RelatedObjectCountField('aggregates')
class Meta: class Meta:
model = models.RIR model = models.RIR
@ -116,10 +117,11 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer):
class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer): class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
group = NestedFHRPGroupSerializer()
class Meta: class Meta:
model = models.FHRPGroupAssignment model = models.FHRPGroupAssignment
fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority'] fields = ['id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority']
# #
@ -131,8 +133,8 @@ class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
) )
class NestedRoleSerializer(WritableNestedSerializer): class NestedRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
prefix_count = serializers.IntegerField(read_only=True) prefix_count = RelatedObjectCountField('prefixes')
vlan_count = serializers.IntegerField(read_only=True) vlan_count = RelatedObjectCountField('vlans')
class Meta: class Meta:
model = models.Role model = models.Role
@ -144,7 +146,7 @@ class NestedRoleSerializer(WritableNestedSerializer):
) )
class NestedVLANGroupSerializer(WritableNestedSerializer): class NestedVLANGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
vlan_count = serializers.IntegerField(read_only=True) vlan_count = RelatedObjectCountField('vlans')
class Meta: class Meta:
model = models.VLANGroup model = models.VLANGroup

View File

@ -6,7 +6,7 @@ from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerial
from ipam.choices import * from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
from ipam.models import * from ipam.models import *
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
@ -33,6 +33,7 @@ class ASNRangeSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags', 'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'asn_count', 'custom_fields', 'created', 'last_updated', 'asn_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -43,8 +44,10 @@ class ASNSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
rir = NestedRIRSerializer(required=False, allow_null=True) rir = NestedRIRSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
site_count = serializers.IntegerField(read_only=True)
provider_count = serializers.IntegerField(read_only=True) # Related object counts
site_count = RelatedObjectCountField('sites')
provider_count = RelatedObjectCountField('providers')
class Meta: class Meta:
model = ASN model = ASN
@ -52,6 +55,7 @@ class ASNSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'site_count', 'provider_count', 'created', 'last_updated', 'site_count', 'provider_count',
] ]
brief_fields = ('id', 'url', 'display', 'asn', 'description')
class AvailableASNSerializer(serializers.Serializer): class AvailableASNSerializer(serializers.Serializer):
@ -90,8 +94,10 @@ class VRFSerializer(NetBoxModelSerializer):
required=False, required=False,
many=True many=True
) )
ipaddress_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True) # Related object counts
ipaddress_count = RelatedObjectCountField('ip_addresses')
prefix_count = RelatedObjectCountField('prefixes')
class Meta: class Meta:
model = VRF model = VRF
@ -100,6 +106,7 @@ class VRFSerializer(NetBoxModelSerializer):
'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count',
'prefix_count', 'prefix_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'rd', 'description', 'prefix_count')
# #
@ -116,6 +123,7 @@ class RouteTargetSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -124,7 +132,9 @@ class RouteTargetSerializer(NetBoxModelSerializer):
class RIRSerializer(NetBoxModelSerializer): class RIRSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
aggregate_count = serializers.IntegerField(read_only=True)
# Related object counts
aggregate_count = RelatedObjectCountField('aggregates')
class Meta: class Meta:
model = RIR model = RIR
@ -132,6 +142,7 @@ class RIRSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'aggregate_count', 'last_updated', 'aggregate_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'aggregate_count')
class AggregateSerializer(NetBoxModelSerializer): class AggregateSerializer(NetBoxModelSerializer):
@ -147,6 +158,7 @@ class AggregateSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description')
# #
@ -163,6 +175,7 @@ class FHRPGroupSerializer(NetBoxModelSerializer):
'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments', 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses', 'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses',
] ]
brief_fields = ('id', 'url', 'display', 'protocol', 'group_id', 'description')
class FHRPGroupAssignmentSerializer(NetBoxModelSerializer): class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
@ -179,6 +192,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created', 'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority')
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_interface(self, obj): def get_interface(self, obj):
@ -195,8 +209,10 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
class RoleSerializer(NetBoxModelSerializer): class RoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
prefix_count = serializers.IntegerField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True) # Related object counts
prefix_count = RelatedObjectCountField('prefixes')
vlan_count = RelatedObjectCountField('vlans')
class Meta: class Meta:
model = Role model = Role
@ -204,6 +220,7 @@ class RoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'prefix_count', 'vlan_count', 'last_updated', 'prefix_count', 'vlan_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count')
class VLANGroupSerializer(NetBoxModelSerializer): class VLANGroupSerializer(NetBoxModelSerializer):
@ -218,15 +235,18 @@ class VLANGroupSerializer(NetBoxModelSerializer):
) )
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True) scope = serializers.SerializerMethodField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True)
utilization = serializers.CharField(read_only=True) utilization = serializers.CharField(read_only=True)
# Related object counts
vlan_count = RelatedObjectCountField('vlans')
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid', 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
validators = [] validators = []
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
@ -247,7 +267,9 @@ class VLANSerializer(NetBoxModelSerializer):
status = ChoiceField(choices=VLANStatusChoices, required=False) status = ChoiceField(choices=VLANStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True) role = NestedRoleSerializer(required=False, allow_null=True)
l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True) l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True)
prefix_count = serializers.IntegerField(read_only=True)
# Related object counts
prefix_count = RelatedObjectCountField('prefixes')
class Meta: class Meta:
model = VLAN model = VLAN
@ -255,6 +277,7 @@ class VLANSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description',
'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count',
] ]
brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')
class AvailableVLANSerializer(serializers.Serializer): class AvailableVLANSerializer(serializers.Serializer):
@ -315,6 +338,7 @@ class PrefixSerializer(NetBoxModelSerializer):
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
'_depth', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
class PrefixLengthSerializer(serializers.Serializer): class PrefixLengthSerializer(serializers.Serializer):
@ -385,6 +409,7 @@ class IPRangeSerializer(NetBoxModelSerializer):
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'mark_utilized', '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')
# #
@ -415,6 +440,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments', 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'family', 'address', 'description')
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_assigned_object(self, obj): def get_assigned_object(self, obj):
@ -457,9 +483,10 @@ class ServiceTemplateSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = ServiceTemplate model = ServiceTemplate
fields = [ fields = [
'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')
class ServiceSerializer(NetBoxModelSerializer): class ServiceSerializer(NetBoxModelSerializer):
@ -477,6 +504,7 @@ class ServiceSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Service model = Service
fields = [ fields = [
'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')

View File

@ -3,6 +3,7 @@ from copy import deepcopy
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from netaddr import IPSet from netaddr import IPSet
@ -12,8 +13,6 @@ from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.views import APIView from rest_framework.views import APIView
from circuits.models import Provider
from dcim.models import Site
from ipam import filtersets from ipam import filtersets
from ipam.models import * from ipam.models import *
from ipam.utils import get_next_available_prefix from ipam.utils import get_next_available_prefix
@ -22,7 +21,6 @@ from netbox.api.viewsets.mixins import ObjectValidationMixin
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import ADVISORY_LOCK_KEYS from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.utils import count_related
from . import serializers from . import serializers
@ -39,64 +37,49 @@ class IPAMRootView(APIRootView):
# #
class ASNRangeViewSet(NetBoxModelViewSet): class ASNRangeViewSet(NetBoxModelViewSet):
queryset = ASNRange.objects.prefetch_related('tenant', 'rir').all() queryset = ASNRange.objects.all()
serializer_class = serializers.ASNRangeSerializer serializer_class = serializers.ASNRangeSerializer
filterset_class = filtersets.ASNRangeFilterSet filterset_class = filtersets.ASNRangeFilterSet
class ASNViewSet(NetBoxModelViewSet): class ASNViewSet(NetBoxModelViewSet):
queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate( queryset = ASN.objects.all()
site_count=count_related(Site, 'asns'),
provider_count=count_related(Provider, 'asns')
)
serializer_class = serializers.ASNSerializer serializer_class = serializers.ASNSerializer
filterset_class = filtersets.ASNFilterSet filterset_class = filtersets.ASNFilterSet
class VRFViewSet(NetBoxModelViewSet): class VRFViewSet(NetBoxModelViewSet):
queryset = VRF.objects.prefetch_related('tenant').prefetch_related( queryset = VRF.objects.all()
'import_targets', 'export_targets', 'tags'
).annotate(
ipaddress_count=count_related(IPAddress, 'vrf'),
prefix_count=count_related(Prefix, 'vrf')
)
serializer_class = serializers.VRFSerializer serializer_class = serializers.VRFSerializer
filterset_class = filtersets.VRFFilterSet filterset_class = filtersets.VRFFilterSet
class RouteTargetViewSet(NetBoxModelViewSet): class RouteTargetViewSet(NetBoxModelViewSet):
queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags') queryset = RouteTarget.objects.all()
serializer_class = serializers.RouteTargetSerializer serializer_class = serializers.RouteTargetSerializer
filterset_class = filtersets.RouteTargetFilterSet filterset_class = filtersets.RouteTargetFilterSet
class RIRViewSet(NetBoxModelViewSet): class RIRViewSet(NetBoxModelViewSet):
queryset = RIR.objects.annotate( queryset = RIR.objects.all()
aggregate_count=count_related(Aggregate, 'rir')
).prefetch_related('tags')
serializer_class = serializers.RIRSerializer serializer_class = serializers.RIRSerializer
filterset_class = filtersets.RIRFilterSet filterset_class = filtersets.RIRFilterSet
class AggregateViewSet(NetBoxModelViewSet): class AggregateViewSet(NetBoxModelViewSet):
queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags') queryset = Aggregate.objects.all()
serializer_class = serializers.AggregateSerializer serializer_class = serializers.AggregateSerializer
filterset_class = filtersets.AggregateFilterSet filterset_class = filtersets.AggregateFilterSet
class RoleViewSet(NetBoxModelViewSet): class RoleViewSet(NetBoxModelViewSet):
queryset = Role.objects.annotate( queryset = Role.objects.all()
prefix_count=count_related(Prefix, 'role'),
vlan_count=count_related(VLAN, 'role')
).prefetch_related('tags')
serializer_class = serializers.RoleSerializer serializer_class = serializers.RoleSerializer
filterset_class = filtersets.RoleFilterSet filterset_class = filtersets.RoleFilterSet
class PrefixViewSet(NetBoxModelViewSet): class PrefixViewSet(NetBoxModelViewSet):
queryset = Prefix.objects.prefetch_related( queryset = Prefix.objects.all()
'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
)
serializer_class = serializers.PrefixSerializer serializer_class = serializers.PrefixSerializer
filterset_class = filtersets.PrefixFilterSet filterset_class = filtersets.PrefixFilterSet
@ -109,7 +92,7 @@ class PrefixViewSet(NetBoxModelViewSet):
class IPRangeViewSet(NetBoxModelViewSet): class IPRangeViewSet(NetBoxModelViewSet):
queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags') queryset = IPRange.objects.all()
serializer_class = serializers.IPRangeSerializer serializer_class = serializers.IPRangeSerializer
filterset_class = filtersets.IPRangeFilterSet filterset_class = filtersets.IPRangeFilterSet
@ -117,9 +100,7 @@ class IPRangeViewSet(NetBoxModelViewSet):
class IPAddressViewSet(NetBoxModelViewSet): class IPAddressViewSet(NetBoxModelViewSet):
queryset = IPAddress.objects.prefetch_related( queryset = IPAddress.objects.all()
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
)
serializer_class = serializers.IPAddressSerializer serializer_class = serializers.IPAddressSerializer
filterset_class = filtersets.IPAddressFilterSet filterset_class = filtersets.IPAddressFilterSet
@ -137,44 +118,39 @@ class IPAddressViewSet(NetBoxModelViewSet):
class FHRPGroupViewSet(NetBoxModelViewSet): class FHRPGroupViewSet(NetBoxModelViewSet):
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') queryset = FHRPGroup.objects.all()
serializer_class = serializers.FHRPGroupSerializer serializer_class = serializers.FHRPGroupSerializer
filterset_class = filtersets.FHRPGroupFilterSet filterset_class = filtersets.FHRPGroupFilterSet
brief_prefetch_fields = ('ip_addresses',)
class FHRPGroupAssignmentViewSet(NetBoxModelViewSet): class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'interface') queryset = FHRPGroupAssignment.objects.all()
serializer_class = serializers.FHRPGroupAssignmentSerializer serializer_class = serializers.FHRPGroupAssignmentSerializer
filterset_class = filtersets.FHRPGroupAssignmentFilterSet filterset_class = filtersets.FHRPGroupAssignmentFilterSet
class VLANGroupViewSet(NetBoxModelViewSet): class VLANGroupViewSet(NetBoxModelViewSet):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') queryset = VLANGroup.objects.annotate_utilization()
serializer_class = serializers.VLANGroupSerializer serializer_class = serializers.VLANGroupSerializer
filterset_class = filtersets.VLANGroupFilterSet filterset_class = filtersets.VLANGroupFilterSet
class VLANViewSet(NetBoxModelViewSet): class VLANViewSet(NetBoxModelViewSet):
queryset = VLAN.objects.prefetch_related( queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags' 'l2vpn_terminations', # Referenced by VLANSerializer.l2vpn_termination
).annotate(
prefix_count=count_related(Prefix, 'vlan')
) )
serializer_class = serializers.VLANSerializer serializer_class = serializers.VLANSerializer
filterset_class = filtersets.VLANFilterSet filterset_class = filtersets.VLANFilterSet
class ServiceTemplateViewSet(NetBoxModelViewSet): class ServiceTemplateViewSet(NetBoxModelViewSet):
queryset = ServiceTemplate.objects.prefetch_related('tags') queryset = ServiceTemplate.objects.all()
serializer_class = serializers.ServiceTemplateSerializer serializer_class = serializers.ServiceTemplateSerializer
filterset_class = filtersets.ServiceTemplateFilterSet filterset_class = filtersets.ServiceTemplateFilterSet
class ServiceViewSet(NetBoxModelViewSet): class ServiceViewSet(NetBoxModelViewSet):
queryset = Service.objects.prefetch_related( queryset = Service.objects.all()
'device', 'virtual_machine', 'tags', 'ipaddresses'
)
serializer_class = serializers.ServiceSerializer serializer_class = serializers.ServiceSerializer
filterset_class = filtersets.ServiceFilterSet filterset_class = filtersets.ServiceFilterSet
@ -379,7 +355,7 @@ class AvailablePrefixesView(AvailableObjectsView):
'vrf': parent.vrf.pk if parent.vrf else None, 'vrf': parent.vrf.pk if parent.vrf else None,
}) })
else: else:
raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)") raise ValidationError(_("Insufficient space is available to accommodate the requested prefix size(s)"))
return requested_objects return requested_objects

View File

@ -6,4 +6,8 @@ class IPAMConfig(AppConfig):
verbose_name = "IPAM" verbose_name = "IPAM"
def ready(self): def ready(self):
from netbox.models.features import register_models
from . import signals, search from . import signals, search
# Register models
register_models(*self.get_models())

View File

@ -1,6 +1,7 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models from django.db import models
from django.utils.translation import gettext as _
from netaddr import AddrFormatError, IPNetwork from netaddr import AddrFormatError, IPNetwork
from . import lookups, validators from . import lookups, validators
@ -32,7 +33,7 @@ class BaseIPField(models.Field):
# Always return a netaddr.IPNetwork object. (netaddr.IPAddress does not provide a mask.) # Always return a netaddr.IPNetwork object. (netaddr.IPAddress does not provide a mask.)
return IPNetwork(value) return IPNetwork(value)
except AddrFormatError: except AddrFormatError:
raise ValidationError("Invalid IP address format: {}".format(value)) raise ValidationError(_("Invalid IP address format: {address}").format(address=value))
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
raise ValidationError(e) raise ValidationError(e)

View File

@ -1,6 +1,7 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_ipv4_address, validate_ipv6_address from django.core.validators import validate_ipv4_address, validate_ipv6_address
from django.utils.translation import gettext_lazy as _
from netaddr import IPAddress, IPNetwork, AddrFormatError from netaddr import IPAddress, IPNetwork, AddrFormatError
@ -10,7 +11,7 @@ from netaddr import IPAddress, IPNetwork, AddrFormatError
class IPAddressFormField(forms.Field): class IPAddressFormField(forms.Field):
default_error_messages = { default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).", 'invalid': _("Enter a valid IPv4 or IPv6 address (without a mask)."),
} }
def to_python(self, value): def to_python(self, value):
@ -28,19 +29,19 @@ class IPAddressFormField(forms.Field):
try: try:
validate_ipv6_address(value) validate_ipv6_address(value)
except ValidationError: except ValidationError:
raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value)) raise ValidationError(_("Invalid IPv4/IPv6 address format: {address}").format(address=value))
try: try:
return IPAddress(value) return IPAddress(value)
except ValueError: except ValueError:
raise ValidationError('This field requires an IP address without a mask.') raise ValidationError(_('This field requires an IP address without a mask.'))
except AddrFormatError: except AddrFormatError:
raise ValidationError("Please specify a valid IPv4 or IPv6 address.") raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
class IPNetworkFormField(forms.Field): class IPNetworkFormField(forms.Field):
default_error_messages = { default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).", 'invalid': _("Enter a valid IPv4 or IPv6 address (with CIDR mask)."),
} }
def to_python(self, value): def to_python(self, value):
@ -52,9 +53,9 @@ class IPNetworkFormField(forms.Field):
# Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128. # Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128.
if len(value.split('/')) != 2: if len(value.split('/')) != 2:
raise ValidationError('CIDR mask (e.g. /24) is required.') raise ValidationError(_('CIDR mask (e.g. /24) is required.'))
try: try:
return IPNetwork(value) return IPNetwork(value)
except AddrFormatError: except AddrFormatError:
raise ValidationError("Please specify a valid IPv4 or IPv6 address.") raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))

View File

@ -267,14 +267,20 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
class IPAddressForm(TenancyForm, NetBoxModelForm): class IPAddressForm(TenancyForm, NetBoxModelForm):
interface = DynamicModelChoiceField( interface = DynamicModelChoiceField(
label=_('Interface'),
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
context={
'parent': 'device',
},
selector=True, selector=True,
label=_('Interface'),
) )
vminterface = DynamicModelChoiceField( vminterface = DynamicModelChoiceField(
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
required=False, required=False,
context={
'parent': 'virtual_machine',
},
selector=True, selector=True,
label=_('Interface'), label=_('Interface'),
) )
@ -750,4 +756,4 @@ class ServiceCreateForm(ServiceForm):
if not self.cleaned_data['description']: if not self.cleaned_data['description']:
self.cleaned_data['description'] = service_template.description self.cleaned_data['description'] = service_template.description
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')): elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.") raise forms.ValidationError(_("Must specify name, protocol, and port(s) if not using a service template."))

View File

@ -23,7 +23,7 @@ class AppTest(APITestCase):
class ASNRangeTest(APIViewTestCases.APIViewTestCase): class ASNRangeTest(APIViewTestCases.APIViewTestCase):
model = ASNRange model = ASNRange
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -135,7 +135,7 @@ class ASNRangeTest(APIViewTestCases.APIViewTestCase):
class ASNTest(APIViewTestCases.APIViewTestCase): class ASNTest(APIViewTestCases.APIViewTestCase):
model = ASN model = ASN
brief_fields = ['asn', 'display', 'id', 'url'] brief_fields = ['asn', 'description', 'display', 'id', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -191,7 +191,7 @@ class ASNTest(APIViewTestCases.APIViewTestCase):
class VRFTest(APIViewTestCases.APIViewTestCase): class VRFTest(APIViewTestCases.APIViewTestCase):
model = VRF model = VRF
brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'prefix_count', 'rd', 'url']
create_data = [ create_data = [
{ {
'name': 'VRF 4', 'name': 'VRF 4',
@ -223,7 +223,7 @@ class VRFTest(APIViewTestCases.APIViewTestCase):
class RouteTargetTest(APIViewTestCases.APIViewTestCase): class RouteTargetTest(APIViewTestCases.APIViewTestCase):
model = RouteTarget model = RouteTarget
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'name': '65000:1004', 'name': '65000:1004',
@ -252,7 +252,7 @@ class RouteTargetTest(APIViewTestCases.APIViewTestCase):
class RIRTest(APIViewTestCases.APIViewTestCase): class RIRTest(APIViewTestCases.APIViewTestCase):
model = RIR model = RIR
brief_fields = ['aggregate_count', 'display', 'id', 'name', 'slug', 'url'] brief_fields = ['aggregate_count', 'description', 'display', 'id', 'name', 'slug', 'url']
create_data = [ create_data = [
{ {
'name': 'RIR 4', 'name': 'RIR 4',
@ -284,7 +284,7 @@ class RIRTest(APIViewTestCases.APIViewTestCase):
class AggregateTest(APIViewTestCases.APIViewTestCase): class AggregateTest(APIViewTestCases.APIViewTestCase):
model = Aggregate model = Aggregate
brief_fields = ['display', 'family', 'id', 'prefix', 'url'] brief_fields = ['description', 'display', 'family', 'id', 'prefix', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -323,7 +323,7 @@ class AggregateTest(APIViewTestCases.APIViewTestCase):
class RoleTest(APIViewTestCases.APIViewTestCase): class RoleTest(APIViewTestCases.APIViewTestCase):
model = Role model = Role
brief_fields = ['display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count'] brief_fields = ['description', 'display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count']
create_data = [ create_data = [
{ {
'name': 'Role 4', 'name': 'Role 4',
@ -355,7 +355,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase):
class PrefixTest(APIViewTestCases.APIViewTestCase): class PrefixTest(APIViewTestCases.APIViewTestCase):
model = Prefix model = Prefix
brief_fields = ['_depth', 'display', 'family', 'id', 'prefix', 'url'] brief_fields = ['_depth', 'description', 'display', 'family', 'id', 'prefix', 'url']
create_data = [ create_data = [
{ {
'prefix': '192.168.4.0/24', 'prefix': '192.168.4.0/24',
@ -534,7 +534,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
class IPRangeTest(APIViewTestCases.APIViewTestCase): class IPRangeTest(APIViewTestCases.APIViewTestCase):
model = IPRange model = IPRange
brief_fields = ['display', 'end_address', 'family', 'id', 'start_address', 'url'] brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'start_address', 'url']
create_data = [ create_data = [
{ {
'start_address': '192.168.4.10/24', 'start_address': '192.168.4.10/24',
@ -633,7 +633,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
class IPAddressTest(APIViewTestCases.APIViewTestCase): class IPAddressTest(APIViewTestCases.APIViewTestCase):
model = IPAddress model = IPAddress
brief_fields = ['address', 'display', 'family', 'id', 'url'] brief_fields = ['address', 'description', 'display', 'family', 'id', 'url']
create_data = [ create_data = [
{ {
'address': '192.168.0.4/24', 'address': '192.168.0.4/24',
@ -718,7 +718,7 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
class FHRPGroupTest(APIViewTestCases.APIViewTestCase): class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
model = FHRPGroup model = FHRPGroup
brief_fields = ['display', 'group_id', 'id', 'protocol', 'url'] brief_fields = ['description', 'display', 'group_id', 'id', 'protocol', 'url']
bulk_update_data = { bulk_update_data = {
'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP, 'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP,
'group_id': 200, 'group_id': 200,
@ -760,7 +760,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase): class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
model = FHRPGroupAssignment model = FHRPGroupAssignment
brief_fields = ['display', 'group_id', 'id', 'interface_id', 'interface_type', 'priority', 'url'] brief_fields = ['display', 'group', 'id', 'interface_id', 'interface_type', 'priority', 'url']
bulk_update_data = { bulk_update_data = {
'priority': 100, 'priority': 100,
} }
@ -839,7 +839,7 @@ class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
class VLANGroupTest(APIViewTestCases.APIViewTestCase): class VLANGroupTest(APIViewTestCases.APIViewTestCase):
model = VLANGroup model = VLANGroup
brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count'] brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url', 'vlan_count']
create_data = [ create_data = [
{ {
'name': 'VLAN Group 4', 'name': 'VLAN Group 4',
@ -960,7 +960,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
class VLANTest(APIViewTestCases.APIViewTestCase): class VLANTest(APIViewTestCases.APIViewTestCase):
model = VLAN model = VLAN
brief_fields = ['display', 'id', 'name', 'url', 'vid'] brief_fields = ['description', 'display', 'id', 'name', 'url', 'vid']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1020,7 +1020,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
class ServiceTemplateTest(APIViewTestCases.APIViewTestCase): class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
model = ServiceTemplate model = ServiceTemplate
brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1055,7 +1055,7 @@ class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
class ServiceTest(APIViewTestCases.APIViewTestCase): class ServiceTest(APIViewTestCases.APIViewTestCase):
model = Service model = Service
brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }

View File

@ -1,14 +1,19 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import BaseValidator, RegexValidator from django.core.validators import BaseValidator, RegexValidator
from django.utils.translation import gettext_lazy as _
def prefix_validator(prefix): def prefix_validator(prefix):
if prefix.ip != prefix.cidr.ip: if prefix.ip != prefix.cidr.ip:
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr)) raise ValidationError(
_("{prefix} is not a valid prefix. Did you mean {suggested}?").format(
prefix=prefix, suggested=prefix.cidr
)
)
class MaxPrefixLengthValidator(BaseValidator): class MaxPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be less than or equal to %(limit_value)s.' message = _('The prefix length must be less than or equal to %(limit_value)s.')
code = 'max_prefix_length' code = 'max_prefix_length'
def compare(self, a, b): def compare(self, a, b):
@ -16,7 +21,7 @@ class MaxPrefixLengthValidator(BaseValidator):
class MinPrefixLengthValidator(BaseValidator): class MinPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be greater than or equal to %(limit_value)s.' message = _('The prefix length must be greater than or equal to %(limit_value)s.')
code = 'min_prefix_length' code = 'min_prefix_length'
def compare(self, a, b): def compare(self, a, b):
@ -25,6 +30,6 @@ class MinPrefixLengthValidator(BaseValidator):
DNSValidator = RegexValidator( DNSValidator = RegexValidator(
regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$', regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$',
message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names', message=_('Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names'),
code='invalid' code='invalid'
) )

View File

@ -1,6 +1,7 @@
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from drf_spectacular.utils import extend_schema_field from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from netaddr import IPNetwork from netaddr import IPNetwork
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@ -10,6 +11,7 @@ __all__ = (
'ChoiceField', 'ChoiceField',
'ContentTypeField', 'ContentTypeField',
'IPNetworkSerializer', 'IPNetworkSerializer',
'RelatedObjectCountField',
'SerializedPKRelatedField', 'SerializedPKRelatedField',
) )
@ -58,11 +60,13 @@ class ChoiceField(serializers.Field):
if data == '': if data == '':
if self.allow_blank: if self.allow_blank:
return data return data
raise ValidationError("This field may not be blank.") raise ValidationError(_("This field may not be blank."))
# Provide an explicit error message if the request is trying to write a dict or list # Provide an explicit error message if the request is trying to write a dict or list
if isinstance(data, (dict, list)): if isinstance(data, (dict, list)):
raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.') raise ValidationError(
_('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
)
# Check for string representations of boolean/integer values # Check for string representations of boolean/integer values
if hasattr(data, 'lower'): if hasattr(data, 'lower'):
@ -82,7 +86,7 @@ class ChoiceField(serializers.Field):
except TypeError: # Input is an unhashable type except TypeError: # Input is an unhashable type
pass pass
raise ValidationError(f"{data} is not a valid choice.") raise ValidationError(_("{value} is not a valid choice.").format(value=data))
@property @property
def choices(self): def choices(self):
@ -95,8 +99,8 @@ class ContentTypeField(RelatedField):
Represent a ContentType as '<app_label>.<model>' Represent a ContentType as '<app_label>.<model>'
""" """
default_error_messages = { default_error_messages = {
"does_not_exist": "Invalid content type: {content_type}", "does_not_exist": _("Invalid content type: {content_type}"),
"invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.", "invalid": _("Invalid value. Specify a content type as '<app_label>.<model_name>'."),
} }
def to_internal_value(self, data): def to_internal_value(self, data):
@ -135,3 +139,16 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField):
def to_representation(self, value): def to_representation(self, value):
return self.serializer(value, context={'request': self.context['request']}).data return self.serializer(value, context={'request': self.context['request']}).data
@extend_schema_field(OpenApiTypes.INT64)
class RelatedObjectCountField(serializers.ReadOnlyField):
"""
Represents a read-only integer count of related objects (e.g. the number of racks assigned to a site). This field
is detected by get_annotations_for_serializer() when determining the annotations to be added to a queryset
depending on the serializer fields selected for inclusion in the response.
"""
def __init__(self, relation, **kwargs):
self.relation = relation
super().__init__(**kwargs)

View File

@ -12,6 +12,15 @@ __all__ = (
class BaseModelSerializer(serializers.ModelSerializer): class BaseModelSerializer(serializers.ModelSerializer):
display = serializers.SerializerMethodField(read_only=True) display = serializers.SerializerMethodField(read_only=True)
def __init__(self, *args, requested_fields=None, **kwargs):
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)
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)
def get_display(self, obj): def get_display(self, obj):
return str(obj) return str(obj)

View File

@ -1,4 +1,5 @@
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@ -30,9 +31,12 @@ class WritableNestedSerializer(BaseModelSerializer):
try: try:
return queryset.get(**params) return queryset.get(**params)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise ValidationError(f"Related object not found using the provided attributes: {params}") raise ValidationError(
_("Related object not found using the provided attributes: {params}").format(params=params))
except MultipleObjectsReturned: except MultipleObjectsReturned:
raise ValidationError(f"Multiple objects match the provided attributes: {params}") raise ValidationError(
_("Multiple objects match the provided attributes: {params}").format(params=params)
)
except FieldError as e: except FieldError as e:
raise ValidationError(e) raise ValidationError(e)
@ -42,15 +46,17 @@ class WritableNestedSerializer(BaseModelSerializer):
pk = int(data) pk = int(data)
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValidationError( raise ValidationError(
f"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an " _(
f"unrecognized value: {data}" "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 # Look up object by PK
try: try:
return self.Meta.model.objects.get(pk=pk) return self.Meta.model.objects.get(pk=pk)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise ValidationError(f"Related object not found using the provided numeric ID: {pk}") raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk))
# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers # Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers

View File

@ -1,4 +1,5 @@
import logging import logging
from functools import cached_property
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction from django.db import transaction
@ -9,6 +10,7 @@ from rest_framework import mixins as drf_mixins
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from . import mixins from . import mixins
@ -32,6 +34,8 @@ class BaseViewSet(GenericViewSet):
""" """
Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions. Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
""" """
brief = False
def initial(self, request, *args, **kwargs): def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs) super().initial(request, *args, **kwargs)
@ -40,9 +44,48 @@ class BaseViewSet(GenericViewSet):
if action := HTTP_ACTIONS[request.method]: if action := HTTP_ACTIONS[request.method]:
self.queryset = self.queryset.restrict(request.user, action) self.queryset = self.queryset.restrict(request.user, action)
def initialize_request(self, request, *args, **kwargs):
# Annotate whether brief mode is active
self.brief = request.method == 'GET' and request.GET.get('brief')
return super().initialize_request(request, *args, **kwargs)
def get_queryset(self):
qs = super().get_queryset()
serializer_class = self.get_serializer_class()
# Dynamically resolve prefetches for included serializer fields and attach them to the queryset
if prefetch := get_prefetches_for_serializer(serializer_class, fields_to_include=self.requested_fields):
qs = qs.prefetch_related(*prefetch)
# Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset
if annotations := get_annotations_for_serializer(serializer_class, fields_to_include=self.requested_fields):
qs = qs.annotate(**annotations)
return qs
def get_serializer(self, *args, **kwargs):
# If specific fields have been requested, pass them to the serializer
if self.requested_fields:
kwargs['requested_fields'] = self.requested_fields
return super().get_serializer(*args, **kwargs)
@cached_property
def requested_fields(self):
# An explicit list of fields was requested
if requested_fields := self.request.query_params.get('fields'):
return requested_fields.split(',')
# Brief mode has been enabled for this request
elif self.brief:
serializer_class = self.get_serializer_class()
return getattr(serializer_class.Meta, 'brief_fields', None)
return None
class NetBoxReadOnlyModelViewSet( class NetBoxReadOnlyModelViewSet(
mixins.BriefModeMixin,
mixins.CustomFieldsMixin, mixins.CustomFieldsMixin,
mixins.ExportTemplatesMixin, mixins.ExportTemplatesMixin,
drf_mixins.RetrieveModelMixin, drf_mixins.RetrieveModelMixin,
@ -56,7 +99,6 @@ class NetBoxModelViewSet(
mixins.BulkUpdateModelMixin, mixins.BulkUpdateModelMixin,
mixins.BulkDestroyModelMixin, mixins.BulkDestroyModelMixin,
mixins.ObjectValidationMixin, mixins.ObjectValidationMixin,
mixins.BriefModeMixin,
mixins.CustomFieldsMixin, mixins.CustomFieldsMixin,
mixins.ExportTemplatesMixin, mixins.ExportTemplatesMixin,
drf_mixins.CreateModelMixin, drf_mixins.CreateModelMixin,

View File

@ -1,5 +1,3 @@
import logging
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
@ -8,13 +6,9 @@ from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from extras.models import ExportTemplate from extras.models import ExportTemplate
from netbox.api.exceptions import SerializerNotFound
from netbox.api.serializers import BulkOperationSerializer from netbox.api.serializers import BulkOperationSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
__all__ = ( __all__ = (
'BriefModeMixin',
'BulkDestroyModelMixin', 'BulkDestroyModelMixin',
'BulkUpdateModelMixin', 'BulkUpdateModelMixin',
'CustomFieldsMixin', 'CustomFieldsMixin',
@ -24,52 +18,6 @@ __all__ = (
) )
class BriefModeMixin:
"""
Enables brief mode support, so that the client can invoke a model's nested serializer by passing e.g.
GET /api/dcim/sites/?brief=True
"""
brief = False
brief_prefetch_fields = []
def initialize_request(self, request, *args, **kwargs):
# Annotate whether brief mode is active
self.brief = request.method == 'GET' and request.GET.get('brief')
return super().initialize_request(request, *args, **kwargs)
def get_serializer_class(self):
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
if self.brief:
logger.debug("Request is for 'brief' format; initializing nested serializer")
try:
return get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX)
except SerializerNotFound:
logger.debug(
f"Nested serializer for {self.queryset.model} not found! Using serializer {self.serializer_class}"
)
return self.serializer_class
def get_queryset(self):
qs = super().get_queryset()
if self.brief:
serializer_class = self.get_serializer_class()
# Clear any annotations for fields not present on the nested serializer
for annotation in list(qs.query.annotations.keys()):
if annotation not in serializer_class().fields:
qs.query.annotations.pop(annotation)
# Clear any prefetches from the queryset and append only brief_prefetch_fields (if any)
return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
return qs
class CustomFieldsMixin: class CustomFieldsMixin:
""" """
For models which support custom fields, populate the `custom_fields` context. For models which support custom fields, populate the `custom_fields` context.

View File

@ -4,12 +4,13 @@ from collections import defaultdict
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend 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.core.exceptions import ImproperlyConfigured
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from users.constants import CONSTRAINT_TOKEN_USER from users.constants import CONSTRAINT_TOKEN_USER
from users.models import ObjectPermission from users.models import Group, ObjectPermission
from utilities.permissions import ( from utilities.permissions import (
permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct, permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
) )
@ -42,6 +43,7 @@ AUTH_BACKEND_ATTRS = {
'hubspot': ('HubSpot', 'hubspot'), 'hubspot': ('HubSpot', 'hubspot'),
'keycloak': ('Keycloak', None), 'keycloak': ('Keycloak', None),
'microsoft-graph': ('Microsoft Graph', 'microsoft'), 'microsoft-graph': ('Microsoft Graph', 'microsoft'),
'oidc': ('OpenID Connect', None),
'okta': ('Okta', None), 'okta': ('Okta', None),
'okta-openidconnect': ('Okta (OIDC)', None), 'okta-openidconnect': ('Okta (OIDC)', None),
'salesforce-oauth2': ('Salesforce', 'salesforce'), 'salesforce-oauth2': ('Salesforce', 'salesforce'),
@ -132,7 +134,9 @@ class ObjectPermissionMixin:
# Sanity check: Ensure that the requested permission applies to the specified object # Sanity check: Ensure that the requested permission applies to the specified object
model = obj._meta.concrete_model model = obj._meta.concrete_model
if model._meta.label_lower != '.'.join((app_label, model_name)): if model._meta.label_lower != '.'.join((app_label, model_name)):
raise ValueError(f"Invalid permission {perm} for model {model}") raise ValueError(_("Invalid permission {permission} for model {model}").format(
permission=perm, model=model
))
# Compile a QuerySet filter that matches all instances of the specified model # Compile a QuerySet filter that matches all instances of the specified model
tokens = { tokens = {

View File

@ -4,6 +4,7 @@ import threading
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.db.utils import DatabaseError from django.db.utils import DatabaseError
from django.utils.translation import gettext_lazy as _
from .parameters import PARAMS from .parameters import PARAMS
@ -63,7 +64,7 @@ class Config:
if item in self.defaults: if item in self.defaults:
return self.defaults[item] return self.defaults[item]
raise AttributeError(f"Invalid configuration parameter: {item}") raise AttributeError(_("Invalid configuration parameter: {item}").format(item=item))
def _populate_from_cache(self): def _populate_from_cache(self):
"""Populate config data from Redis cache""" """Populate config data from Redis cache"""

View File

@ -35,7 +35,9 @@ class CustomFieldsMixin:
Return the ContentType of the form's model. Return the ContentType of the form's model.
""" """
if not getattr(self, 'model', None): if not getattr(self, 'model', None):
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.") raise NotImplementedError(_("{class_name} must specify a model class.").format(
class_name=self.__class__.__name__
))
return ContentType.objects.get_for_model(self.model) return ContentType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type): def _get_custom_fields(self, content_type):

View File

@ -5,8 +5,6 @@ from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.db.models.signals import class_prepared
from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
@ -14,7 +12,7 @@ from taggit.managers import TaggableManager
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.models import ContentType from core.models import ContentType
from extras.choices import * from extras.choices import *
from extras.utils import is_taggable, register_features from extras.utils import is_taggable
from netbox.config import get_config from netbox.config import get_config
from netbox.registry import registry from netbox.registry import registry
from netbox.signals import post_clean from netbox.signals import post_clean
@ -37,6 +35,7 @@ __all__ = (
'JournalingMixin', 'JournalingMixin',
'SyncedDataMixin', 'SyncedDataMixin',
'TagsMixin', 'TagsMixin',
'register_models',
) )
@ -275,16 +274,20 @@ class CustomFieldsMixin(models.Model):
# Validate all field values # Validate all field values
for field_name, value in self.custom_field_data.items(): for field_name, value in self.custom_field_data.items():
if field_name not in custom_fields: if field_name not in custom_fields:
raise ValidationError(f"Unknown field name '{field_name}' in custom field data.") raise ValidationError(_("Unknown field name '{name}' in custom field data.").format(
name=field_name
))
try: try:
custom_fields[field_name].validate(value) custom_fields[field_name].validate(value)
except ValidationError as e: except ValidationError as e:
raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}") raise ValidationError(_("Invalid value for custom field '{name}': {error}").format(
name=field_name, error=e.message
))
# Check for missing required values # Check for missing required values
for cf in custom_fields.values(): for cf in custom_fields.values():
if cf.required and cf.name not in self.custom_field_data: if cf.required and cf.name not in self.custom_field_data:
raise ValidationError(f"Missing required custom field '{cf.name}'.") raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
class CustomLinksMixin(models.Model): class CustomLinksMixin(models.Model):
@ -489,10 +492,10 @@ class SyncedDataMixin(models.Model):
# Create/delete AutoSyncRecord as needed # Create/delete AutoSyncRecord as needed
content_type = ContentType.objects.get_for_model(self) content_type = ContentType.objects.get_for_model(self)
if self.auto_sync_enabled: if self.auto_sync_enabled:
AutoSyncRecord.objects.get_or_create( AutoSyncRecord.objects.update_or_create(
datafile=self.data_file,
object_type=content_type, object_type=content_type,
object_id=self.pk object_id=self.pk,
defaults={'datafile': self.data_file}
) )
else: else:
AutoSyncRecord.objects.filter( AutoSyncRecord.objects.filter(
@ -547,7 +550,9 @@ class SyncedDataMixin(models.Model):
Inheriting models must override this method with specific logic to copy data from the assigned DataFile Inheriting models must override this method with specific logic to copy data from the assigned DataFile
to the local instance. This method should *NOT* call save() on the instance. to the local instance. This method should *NOT* call save() on the instance.
""" """
raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.") raise NotImplementedError(_("{class_name} must implement a sync_data() method.").format(
class_name=self.__class__
))
# #
@ -576,36 +581,49 @@ registry['model_features'].update({
}) })
@receiver(class_prepared) def register_models(*models):
def _register_features(sender, **kwargs): """
Register one or more models in NetBox. This entails:
- Determining whether the model is considered "public" (available for reference by other models)
- Registering which features the model supports (e.g. bookmarks, custom fields, etc.)
- Registering any feature-specific views for the model (e.g. ObjectJournalView instances)
register_model() should be called for each relevant model under the ready() of an app's AppConfig class.
"""
for model in models:
app_label, model_name = model._meta.label_lower.split('.')
# Register public models
if not getattr(model, '_netbox_private', False):
registry['models'][app_label].add(model_name)
# Record each applicable feature for the model in the registry # Record each applicable feature for the model in the registry
features = { features = {
feature for feature, cls in FEATURES_MAP.items() if issubclass(sender, cls) feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls)
} }
register_features(sender, features) for feature in features:
try:
registry['model_features'][feature][app_label].add(model_name)
except KeyError:
raise KeyError(
f"{feature} is not a valid model feature! Valid keys are: {registry['model_features'].keys()}"
)
# Register applicable feature views for the model # Register applicable feature views for the model
if issubclass(sender, JournalingMixin): if issubclass(model, JournalingMixin):
register_model_view( register_model_view(model, 'journal', kwargs={'model': model})(
sender, 'netbox.views.generic.ObjectJournalView'
'journal', )
kwargs={'model': sender} if issubclass(model, ChangeLoggingMixin):
)('netbox.views.generic.ObjectJournalView') register_model_view(model, 'changelog', kwargs={'model': model})(
if issubclass(sender, ChangeLoggingMixin): 'netbox.views.generic.ObjectChangeLogView'
register_model_view( )
sender, if issubclass(model, JobsMixin):
'changelog', register_model_view(model, 'jobs', kwargs={'model': model})(
kwargs={'model': sender} 'netbox.views.generic.ObjectJobsView'
)('netbox.views.generic.ObjectChangeLogView') )
if issubclass(sender, JobsMixin): if issubclass(model, SyncedDataMixin):
register_model_view( register_model_view(model, 'sync', kwargs={'model': model})(
sender, 'netbox.views.generic.ObjectSyncDataView'
'jobs', )
kwargs={'model': sender}
)('netbox.views.generic.ObjectJobsView')
if issubclass(sender, SyncedDataMixin):
register_model_view(
sender,
'sync',
kwargs={'model': sender}
)('netbox.views.generic.ObjectSyncDataView')

View File

@ -392,19 +392,19 @@ ADMIN_MENU = Menu(
), ),
# Proxy model for auth.Group # Proxy model for auth.Group
MenuItem( MenuItem(
link=f'users:netboxgroup_list', link=f'users:group_list',
link_text=_('Groups'), link_text=_('Groups'),
permissions=[f'auth.view_group'], permissions=[f'auth.view_group'],
staff_only=True, staff_only=True,
buttons=( buttons=(
MenuItemButton( MenuItemButton(
link=f'users:netboxgroup_add', link=f'users:group_add',
title='Add', title='Add',
icon_class='mdi mdi-plus-thick', icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_group'] permissions=[f'auth.add_group']
), ),
MenuItemButton( MenuItemButton(
link=f'users:netboxgroup_import', link=f'users:group_import',
title='Import', title='Import',
icon_class='mdi mdi-upload', icon_class='mdi mdi-upload',
permissions=[f'auth.add_group'] permissions=[f'auth.add_group']

View File

@ -94,6 +94,11 @@ class PluginConfig(AppConfig):
pass pass
def ready(self): def ready(self):
from netbox.models.features import register_models
# Register models
register_models(*self.get_models())
plugin_name = self.name.rsplit('.', 1)[-1] plugin_name = self.name.rsplit('.', 1)[-1]
# Register search extensions (if defined) # Register search extensions (if defined)

View File

@ -1,6 +1,7 @@
from netbox.navigation import MenuGroup from netbox.navigation import MenuGroup
from utilities.choices import ButtonColorChoices from utilities.choices import ButtonColorChoices
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _
__all__ = ( __all__ = (
'PluginMenu', 'PluginMenu',
@ -42,11 +43,11 @@ class PluginMenuItem:
self.staff_only = staff_only self.staff_only = staff_only
if permissions is not None: if permissions is not None:
if type(permissions) not in (list, tuple): if type(permissions) not in (list, tuple):
raise TypeError("Permissions must be passed as a tuple or list.") raise TypeError(_("Permissions must be passed as a tuple or list."))
self.permissions = permissions self.permissions = permissions
if buttons is not None: if buttons is not None:
if type(buttons) not in (list, tuple): if type(buttons) not in (list, tuple):
raise TypeError("Buttons must be passed as a tuple or list.") raise TypeError(_("Buttons must be passed as a tuple or list."))
self.buttons = buttons self.buttons = buttons
@ -64,9 +65,9 @@ class PluginMenuButton:
self.icon_class = icon_class self.icon_class = icon_class
if permissions is not None: if permissions is not None:
if type(permissions) not in (list, tuple): if type(permissions) not in (list, tuple):
raise TypeError("Permissions must be passed as a tuple or list.") raise TypeError(_("Permissions must be passed as a tuple or list."))
self.permissions = permissions self.permissions = permissions
if color is not None: if color is not None:
if color not in ButtonColorChoices.values(): if color not in ButtonColorChoices.values():
raise ValueError("Button color must be a choice within ButtonColorChoices.") raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
self.color = color self.color = color

View File

@ -1,5 +1,6 @@
import inspect import inspect
from django.utils.translation import gettext_lazy as _
from netbox.registry import registry from netbox.registry import registry
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
from .templates import PluginTemplateExtension from .templates import PluginTemplateExtension
@ -20,18 +21,32 @@ def register_template_extensions(class_list):
# Validation # Validation
for template_extension in class_list: for template_extension in class_list:
if not inspect.isclass(template_extension): if not inspect.isclass(template_extension):
raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!") raise TypeError(
_("PluginTemplateExtension class {template_extension} was passed as an instance!").format(
template_extension=template_extension
)
)
if not issubclass(template_extension, PluginTemplateExtension): if not issubclass(template_extension, PluginTemplateExtension):
raise TypeError(f"{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!") raise TypeError(
_("{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!").format(
template_extension=template_extension
)
)
if template_extension.model is None: if template_extension.model is None:
raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!") raise TypeError(
_("PluginTemplateExtension class {template_extension} does not define a valid model!").format(
template_extension=template_extension
)
)
registry['plugins']['template_extensions'][template_extension.model].append(template_extension) registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
def register_menu(menu): def register_menu(menu):
if not isinstance(menu, PluginMenu): if not isinstance(menu, PluginMenu):
raise TypeError(f"{menu} must be an instance of netbox.plugins.PluginMenu") raise TypeError(_("{item} must be an instance of netbox.plugins.PluginMenuItem").format(
item=menu_link
))
registry['plugins']['menus'].append(menu) registry['plugins']['menus'].append(menu)
@ -42,10 +57,14 @@ def register_menu_items(section_name, class_list):
# Validation # Validation
for menu_link in class_list: for menu_link in class_list:
if not isinstance(menu_link, PluginMenuItem): if not isinstance(menu_link, PluginMenuItem):
raise TypeError(f"{menu_link} must be an instance of netbox.plugins.PluginMenuItem") raise TypeError(_("{menu_link} must be an instance of netbox.plugins.PluginMenuItem").format(
menu_link=menu_link
))
for button in menu_link.buttons: for button in menu_link.buttons:
if not isinstance(button, PluginMenuButton): if not isinstance(button, PluginMenuButton):
raise TypeError(f"{button} must be an instance of netbox.plugins.PluginMenuButton") raise TypeError(_("{button} must be an instance of netbox.plugins.PluginMenuButton").format(
button=button
))
registry['plugins']['menu_items'][section_name] = class_list registry['plugins']['menu_items'][section_name] = class_list

View File

@ -1,4 +1,5 @@
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.translation import gettext as _
__all__ = ( __all__ = (
'PluginTemplateExtension', 'PluginTemplateExtension',
@ -31,7 +32,7 @@ class PluginTemplateExtension:
if extra_context is None: if extra_context is None:
extra_context = {} extra_context = {}
elif not isinstance(extra_context, dict): elif not isinstance(extra_context, dict):
raise TypeError("extra_context must be a dictionary") raise TypeError(_("extra_context must be a dictionary"))
return get_template(template_name).render({**self.context, **extra_context}) return get_template(template_name).render({**self.context, **extra_context})

View File

@ -1,4 +1,5 @@
import collections import collections
from django.utils.translation import gettext as _
class Registry(dict): class Registry(dict):
@ -10,13 +11,13 @@ class Registry(dict):
try: try:
return super().__getitem__(key) return super().__getitem__(key)
except KeyError: except KeyError:
raise KeyError(f"Invalid store: {key}") raise KeyError(_("Invalid store: {key}").format(key=key))
def __setitem__(self, key, value): def __setitem__(self, key, value):
raise TypeError("Cannot add stores to registry after initialization") raise TypeError(_("Cannot add stores to registry after initialization"))
def __delitem__(self, key): def __delitem__(self, key):
raise TypeError("Cannot delete stores from registry") raise TypeError(_("Cannot delete stores from registry"))
# Initialize the global registry # Initialize the global registry

View File

@ -29,7 +29,7 @@ from netbox.plugins import PluginConfig
# Environment setup # Environment setup
# #
VERSION = '3.7.3-dev' VERSION = '4.0.0-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -156,6 +156,7 @@ REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', [])
REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', []) REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
# Required by extras/migrations/0109_script_models.py
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60) RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
@ -579,7 +580,6 @@ SOCIAL_AUTH_PIPELINE = (
'social_core.pipeline.social_auth.social_uid', 'social_core.pipeline.social_auth.social_uid',
'social_core.pipeline.social_auth.social_user', 'social_core.pipeline.social_auth.social_user',
'social_core.pipeline.user.get_username', 'social_core.pipeline.user.get_username',
'social_core.pipeline.social_auth.associate_by_email',
'social_core.pipeline.user.create_user', 'social_core.pipeline.user.create_user',
'social_core.pipeline.social_auth.associate_user', 'social_core.pipeline.social_auth.associate_user',
'netbox.authentication.user_default_groups_handler', 'netbox.authentication.user_default_groups_handler',

Some files were not shown because too many files have changed in this diff Show More