Merge branch 'feature' into 14736-htmx
This commit is contained in:
commit
c377d8360c
|
@ -13,7 +13,9 @@ body:
|
|||
- type: dropdown
|
||||
attributes:
|
||||
label: Deployment Type
|
||||
description: How are you running NetBox?
|
||||
description: >
|
||||
How are you running NetBox? (For issues with the Docker image, please go to the
|
||||
[netbox-docker](https://github.com/netbox-community/netbox-docker) repo.)
|
||||
options:
|
||||
- Self-hosted
|
||||
- NetBox Cloud
|
||||
|
@ -23,7 +25,7 @@ body:
|
|||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.2
|
||||
placeholder: v3.7.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
@ -14,7 +14,7 @@ body:
|
|||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.2
|
||||
placeholder: v3.7.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
@ -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/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://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>
|
||||
<p></p>
|
||||
</div>
|
||||
|
|
|
@ -105,7 +105,7 @@ mkdocs-material
|
|||
mkdocstrings[python-legacy]
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||
netaddr
|
||||
|
||||
# Python bindings to the ammonia HTML sanitization library.
|
||||
|
|
|
@ -67,7 +67,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
|
|||
|
||||
Default: `|` (Pipe)
|
||||
|
||||
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
The Separator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -476,7 +476,7 @@ class NewBranchScript(Script):
|
|||
name=f'{site.slug}-switch{i}',
|
||||
site=site,
|
||||
status=DeviceStatusChoices.STATUS_PLANNED,
|
||||
role=switch_role
|
||||
device_role=switch_role
|
||||
)
|
||||
switch.full_clean()
|
||||
switch.save()
|
||||
|
|
|
@ -1,6 +1,41 @@
|
|||
# NetBox v3.7
|
||||
|
||||
## v3.7.3 (FUTURE)
|
||||
## v3.7.4 (FUTURE)
|
||||
|
||||
---
|
||||
|
||||
## v3.7.3 (2024-02-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#14587](https://github.com/netbox-community/netbox/issues/14587) - Display a human-friendly name for the OpenID Connect remote auth backend
|
||||
* [#14946](https://github.com/netbox-community/netbox/issues/14946) - Remove `associate_by_email()` from default social auth pipeline
|
||||
* [#14966](https://github.com/netbox-community/netbox/issues/14966) - Add PostgreSQL index for object type & ID on CachedValue table to improve performance
|
||||
* [#15177](https://github.com/netbox-community/netbox/issues/15177) - Add "last login" time to user display & REST API serializer
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14058](https://github.com/netbox-community/netbox/issues/14058) - Limit platform options by manufacturer when editing a device or device type
|
||||
* [#14064](https://github.com/netbox-community/netbox/issues/14064) - Resolving parent location should consider assigned site when bulk importing locations
|
||||
* [#14079](https://github.com/netbox-community/netbox/issues/14079) - Ensure changes are logged on related objects when deleting an object referenced via a many-to-many relationship (e.g. tags)
|
||||
* [#14405](https://github.com/netbox-community/netbox/issues/14405) - Clean up formatting of link peers in bulk CSV export of cable termination objects
|
||||
* [#14689](https://github.com/netbox-community/netbox/issues/14689) - Preserve "empty" default values for JSON custom fields
|
||||
* [#14952](https://github.com/netbox-community/netbox/issues/14952) - Update existing AutoSyncRecord when changing the data file of an auto-synced object
|
||||
* [#15059](https://github.com/netbox-community/netbox/issues/15059) - Correct IP address count link in VM interfaces table
|
||||
* [#15067](https://github.com/netbox-community/netbox/issues/15067) - Fix uncaught exception when attempting invalid device bay import
|
||||
* [#15070](https://github.com/netbox-community/netbox/issues/15070) - Fix inclusion of `config_template` field on REST API serializer for virtual machines
|
||||
* [#15084](https://github.com/netbox-community/netbox/issues/15084) - Fix "add export template" link under "export" button on object list views
|
||||
* [#15090](https://github.com/netbox-community/netbox/issues/15090) - Ensure protection rules are evaluated prior to enqueueing events when deleting an object
|
||||
* [#15091](https://github.com/netbox-community/netbox/issues/15091) - Fix designation of the active tab for assigned object when modifying an L2VPN termination
|
||||
* [#15101](https://github.com/netbox-community/netbox/issues/15101) - Correct OpenAPI schema for rack elevation REST API endpoint
|
||||
* [#15115](https://github.com/netbox-community/netbox/issues/15115) - Fix unhandled exception with invalid permission constraints
|
||||
* [#15126](https://github.com/netbox-community/netbox/issues/15126) - `group` field should be optional when creating VPN tunnel via REST API
|
||||
* [#15127](https://github.com/netbox-community/netbox/issues/15127) - Add missing group column to VPN tunnels table
|
||||
* [#15133](https://github.com/netbox-community/netbox/issues/15133) - Fix FHRP group representation on assignments REST API endpoint using brief mode
|
||||
* [#15174](https://github.com/netbox-community/netbox/issues/15174) - Warn that permission constraints are not supported for reports or scripts
|
||||
* [#15184](https://github.com/netbox-community/netbox/issues/15184) - Correct REST API schema definition for `front_image` & `rear_image` on DeviceType
|
||||
* [#15185](https://github.com/netbox-community/netbox/issues/15185) - Ensure error messages pertaining to related objects are displayed on the bulk import form
|
||||
* [#15192](https://github.com/netbox-community/netbox/issues/15192) - Fix exception when viewing current config when no history is present
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -13,6 +13,10 @@
|
|||
|
||||
The NetBox user interface has been completely refreshed and updated.
|
||||
|
||||
#### Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087))
|
||||
|
||||
The REST API now supports specifying which fields to include in the response data.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
|
||||
|
@ -22,6 +26,9 @@ The NetBox user interface has been completely refreshed and updated.
|
|||
* [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12
|
||||
* [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI
|
||||
* [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI
|
||||
* [#14438](https://github.com/netbox-community/netbox/issues/14438) - Track individual custom scripts as database objects
|
||||
* [#15131](https://github.com/netbox-community/netbox/issues/15131) - Automatically annotate related object counts on REST API querysets
|
||||
* [#15238](https://github.com/netbox-community/netbox/issues/15238) - Include the `description` field in "brief" REST API serializations
|
||||
|
||||
### Other Changes
|
||||
|
||||
|
@ -34,5 +41,6 @@ The NetBox user interface has been completely refreshed and updated.
|
|||
* [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin`
|
||||
* [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`)
|
||||
* [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class
|
||||
* [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features
|
||||
* [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices
|
||||
* [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_serializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from circuits.models import *
|
||||
from netbox.api.fields import RelatedObjectCountField
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
|
@ -36,7 +36,7 @@ class NestedProviderNetworkSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedProviderSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
circuit_count = RelatedObjectCountField('circuits')
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
|
@ -64,7 +64,7 @@ class NestedProviderAccountSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedCircuitTypeSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
circuit_count = RelatedObjectCountField('circuits')
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
|
|
|
@ -4,9 +4,9 @@ from circuits.choices import CircuitStatusChoices
|
|||
from circuits.models import *
|
||||
from dcim.api.nested_serializers import NestedSiteSerializer
|
||||
from dcim.api.serializers import CabledObjectSerializer
|
||||
from ipam.models import ASN
|
||||
from ipam.api.nested_serializers import NestedASNSerializer
|
||||
from 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 tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from .nested_serializers import *
|
||||
|
@ -32,7 +32,7 @@ class ProviderSerializer(NetBoxModelSerializer):
|
|||
)
|
||||
|
||||
# Related object counts
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
circuit_count = RelatedObjectCountField('circuits')
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
|
@ -40,6 +40,7 @@ class ProviderSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'circuit_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
|
||||
|
||||
|
||||
#
|
||||
|
@ -56,6 +57,7 @@ class ProviderAccountSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -72,6 +74,7 @@ class ProviderNetworkSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -80,14 +83,17 @@ class ProviderNetworkSerializer(NetBoxModelSerializer):
|
|||
|
||||
class CircuitTypeSerializer(NetBoxModelSerializer):
|
||||
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:
|
||||
model = CircuitType
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'circuit_count',
|
||||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'circuit_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
|
||||
|
||||
|
||||
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
|
@ -120,6 +126,7 @@ class CircuitSerializer(NetBoxModelSerializer):
|
|||
'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'cid', 'description')
|
||||
|
||||
|
||||
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||
|
@ -135,3 +142,4 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
|
|||
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
|
||||
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')
|
||||
|
|
|
@ -4,7 +4,6 @@ from circuits import filtersets
|
|||
from circuits.models import *
|
||||
from dcim.api.views import PassThroughPortMixin
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
|
@ -21,9 +20,7 @@ class CircuitsRootView(APIRootView):
|
|||
#
|
||||
|
||||
class ProviderViewSet(NetBoxModelViewSet):
|
||||
queryset = Provider.objects.annotate(
|
||||
circuit_count=count_related(Circuit, 'provider')
|
||||
)
|
||||
queryset = Provider.objects.all()
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
filterset_class = filtersets.ProviderFilterSet
|
||||
|
||||
|
@ -33,9 +30,7 @@ class ProviderViewSet(NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class CircuitTypeViewSet(NetBoxModelViewSet):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
queryset = CircuitType.objects.all()
|
||||
serializer_class = serializers.CircuitTypeSerializer
|
||||
filterset_class = filtersets.CircuitTypeFilterSet
|
||||
|
||||
|
|
|
@ -6,4 +6,8 @@ class CircuitsConfig(AppConfig):
|
|||
verbose_name = "Circuits"
|
||||
|
||||
def ready(self):
|
||||
from netbox.models.features import register_models
|
||||
from . import signals, search
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
|
|
@ -234,9 +234,9 @@ class CircuitTermination(
|
|||
|
||||
# Must define either site *or* provider network
|
||||
if self.site is None and self.provider_network is None:
|
||||
raise ValidationError("A circuit termination must attach to either a site or a provider network.")
|
||||
raise ValidationError(_("A circuit termination must attach to either a site or a provider network."))
|
||||
if self.site and self.provider_network:
|
||||
raise ValidationError("A circuit termination cannot attach to both a site and a provider network.")
|
||||
raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network."))
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
|
|
@ -18,7 +18,7 @@ class AppTest(APITestCase):
|
|||
|
||||
class ProviderTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Provider
|
||||
brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
|
||||
brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'comments': 'New comments',
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
model = CircuitType
|
||||
brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
|
||||
brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
|
||||
create_data = (
|
||||
{
|
||||
'name': 'Circuit Type 4',
|
||||
|
@ -92,7 +92,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class CircuitTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Circuit
|
||||
brief_fields = ['cid', 'display', 'id', 'url']
|
||||
brief_fields = ['cid', 'description', 'display', 'id', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
@ -149,7 +149,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
|
||||
model = CircuitTermination
|
||||
brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url']
|
||||
brief_fields = ['_occupied', 'cable', 'circuit', 'description', 'display', 'id', 'term_side', 'url']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
@ -208,7 +208,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ProviderAccount
|
||||
brief_fields = ['account', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['account', 'description', 'display', 'id', 'name', 'url']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
@ -251,7 +251,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ProviderNetwork
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
|
|
@ -8,6 +8,7 @@ from drf_spectacular.plumbing import (
|
|||
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
|
||||
)
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import serializers
|
||||
from rest_framework.relations import ManyRelatedField
|
||||
|
||||
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
|
||||
|
|
|
@ -2,7 +2,7 @@ from rest_framework import serializers
|
|||
|
||||
from core.choices 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.utils import get_data_backend_choices
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
|
@ -28,9 +28,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
|
|||
)
|
||||
|
||||
# Related object counts
|
||||
file_count = serializers.IntegerField(
|
||||
read_only=True
|
||||
)
|
||||
file_count = RelatedObjectCountField('datafiles')
|
||||
|
||||
class Meta:
|
||||
model = DataSource
|
||||
|
@ -38,6 +36,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
|
||||
'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class DataFileSerializer(NetBoxModelSerializer):
|
||||
|
@ -53,6 +52,7 @@ class DataFileSerializer(NetBoxModelSerializer):
|
|||
fields = [
|
||||
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'path')
|
||||
|
||||
|
||||
class JobSerializer(BaseModelSerializer):
|
||||
|
@ -71,3 +71,4 @@ class JobSerializer(BaseModelSerializer):
|
|||
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
|
||||
'started', 'completed', 'user', 'data', 'error', 'job_id',
|
||||
]
|
||||
brief_fields = ('url', 'created', 'completed', 'user', 'status')
|
||||
|
|
|
@ -9,7 +9,6 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
|
|||
from core import filtersets
|
||||
from core.models import *
|
||||
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
|
@ -22,9 +21,7 @@ class CoreRootView(APIRootView):
|
|||
|
||||
|
||||
class DataSourceViewSet(NetBoxModelViewSet):
|
||||
queryset = DataSource.objects.annotate(
|
||||
file_count=count_related(DataFile, 'source')
|
||||
)
|
||||
queryset = DataSource.objects.all()
|
||||
serializer_class = serializers.DataSourceSerializer
|
||||
filterset_class = filtersets.DataSourceFilterSet
|
||||
|
||||
|
|
|
@ -16,5 +16,9 @@ class CoreConfig(AppConfig):
|
|||
name = "core"
|
||||
|
||||
def ready(self):
|
||||
from core.api import schema # noqa
|
||||
from netbox.models.features import register_models
|
||||
from . import data_backends, search
|
||||
from core.api import schema # noqa: E402
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
|
|
@ -102,7 +102,7 @@ class GitBackend(DataBackend):
|
|||
try:
|
||||
porcelain.clone(self.url, local_path.name, **clone_args)
|
||||
except BaseException as e:
|
||||
raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")
|
||||
raise SyncError(_("Fetching remote data failed ({name}): {error}").format(name=type(e).__name__, error=e))
|
||||
|
||||
yield local_path.name
|
||||
|
||||
|
|
|
@ -103,9 +103,9 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
|
|||
super().clean()
|
||||
|
||||
if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'):
|
||||
raise forms.ValidationError("Cannot upload a file and sync from an existing file")
|
||||
raise forms.ValidationError(_("Cannot upload a file and sync from an existing file"))
|
||||
if not self.cleaned_data.get('upload_file') and not self.cleaned_data.get('data_file'):
|
||||
raise forms.ValidationError("Must upload a file or select a data file to sync")
|
||||
raise forms.ValidationError(_("Must upload a file or select a data file to sync"))
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ class ConfigRevision(models.Model):
|
|||
return gettext('Config revision #{id}').format(id=self.pk)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.data:
|
||||
if self.data and item in self.data:
|
||||
return self.data[item]
|
||||
return super().__getattribute__(item)
|
||||
|
||||
|
|
|
@ -177,7 +177,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||
Create/update/delete child DataFiles as necessary to synchronize with the remote source.
|
||||
"""
|
||||
if self.status == DataSourceStatusChoices.SYNCING:
|
||||
raise SyncError("Cannot initiate sync; syncing already in progress.")
|
||||
raise SyncError(_("Cannot initiate sync; syncing already in progress."))
|
||||
|
||||
# Emit the pre_sync signal
|
||||
pre_sync.send(sender=self.__class__, instance=self)
|
||||
|
@ -190,7 +190,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||
backend = self.get_backend()
|
||||
except ModuleNotFoundError as e:
|
||||
raise SyncError(
|
||||
f"There was an error initializing the backend. A dependency needs to be installed: {e}"
|
||||
_("There was an error initializing the backend. A dependency needs to be installed: ") + str(e)
|
||||
)
|
||||
with backend.fetch() as local_path:
|
||||
|
||||
|
|
|
@ -181,7 +181,11 @@ class Job(models.Model):
|
|||
"""
|
||||
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
if status not in valid_statuses:
|
||||
raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}")
|
||||
raise ValueError(
|
||||
_("Invalid status for job termination. Choices are: {choices}").format(
|
||||
choices=', '.join(valid_statuses)
|
||||
)
|
||||
)
|
||||
|
||||
# Mark the job as completed
|
||||
self.status = status
|
||||
|
|
|
@ -16,7 +16,7 @@ class AppTest(APITestCase):
|
|||
|
||||
class DataSourceTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DataSource
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'enabled': False,
|
||||
'description': 'foo bar baz',
|
||||
|
|
|
@ -185,7 +185,7 @@ class ConfigView(generic.ObjectView):
|
|||
except ConfigRevision.DoesNotExist:
|
||||
# Fall back to using the active config data if no record is found
|
||||
return ConfigRevision(
|
||||
data=get_config()
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -2,7 +2,8 @@ from drf_spectacular.utils import extend_schema_serializer
|
|||
from rest_framework import serializers
|
||||
|
||||
from dcim import models
|
||||
from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
|
||||
from netbox.api.fields import RelatedObjectCountField
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'ComponentNestedModuleSerializer',
|
||||
|
@ -110,7 +111,7 @@ class NestedLocationSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedRackRoleSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
rack_count = RelatedObjectCountField('racks')
|
||||
|
||||
class Meta:
|
||||
model = models.RackRole
|
||||
|
@ -122,7 +123,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedRackSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
device_count = RelatedObjectCountField('devices')
|
||||
|
||||
class Meta:
|
||||
model = models.Rack
|
||||
|
@ -150,7 +151,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedManufacturerSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
|
||||
devicetype_count = serializers.IntegerField(read_only=True)
|
||||
devicetype_count = RelatedObjectCountField('device_types')
|
||||
|
||||
class Meta:
|
||||
model = models.Manufacturer
|
||||
|
@ -163,7 +164,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
|
|||
class NestedDeviceTypeSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
||||
manufacturer = NestedManufacturerSerializer(read_only=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
device_count = RelatedObjectCountField('instances')
|
||||
|
||||
class Meta:
|
||||
model = models.DeviceType
|
||||
|
@ -173,7 +174,6 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
|
|||
class NestedModuleTypeSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
|
||||
manufacturer = NestedManufacturerSerializer(read_only=True)
|
||||
# module_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ModuleType
|
||||
|
@ -274,8 +274,8 @@ class NestedInventoryItemTemplateSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedDeviceRoleSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
device_count = RelatedObjectCountField('devices')
|
||||
virtualmachine_count = RelatedObjectCountField('virtual_machines')
|
||||
|
||||
class Meta:
|
||||
model = models.DeviceRole
|
||||
|
@ -287,8 +287,8 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedPlatformSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
device_count = RelatedObjectCountField('devices')
|
||||
virtualmachine_count = RelatedObjectCountField('virtual_machines')
|
||||
|
||||
class Meta:
|
||||
model = models.Platform
|
||||
|
@ -445,7 +445,7 @@ class NestedInventoryItemSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
|
||||
inventoryitem_count = serializers.IntegerField(read_only=True)
|
||||
inventoryitem_count = RelatedObjectCountField('inventory_items')
|
||||
|
||||
class Meta:
|
||||
model = models.InventoryItemRole
|
||||
|
@ -490,7 +490,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedPowerPanelSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
|
||||
powerfeed_count = serializers.IntegerField(read_only=True)
|
||||
powerfeed_count = RelatedObjectCountField('powerfeeds')
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPanel
|
||||
|
|
|
@ -15,7 +15,7 @@ from ipam.api.nested_serializers import (
|
|||
NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
|
||||
)
|
||||
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 (
|
||||
GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer,
|
||||
WritableNestedSerializer,
|
||||
|
@ -114,6 +114,7 @@ class RegionSerializer(NestedGroupModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'site_count', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
|
||||
|
||||
|
||||
class SiteGroupSerializer(NestedGroupModelSerializer):
|
||||
|
@ -127,6 +128,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'site_count', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
|
||||
|
||||
|
||||
class SiteSerializer(NetBoxModelSerializer):
|
||||
|
@ -144,12 +146,12 @@ class SiteSerializer(NetBoxModelSerializer):
|
|||
)
|
||||
|
||||
# Related object counts
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
circuit_count = RelatedObjectCountField('circuit_terminations')
|
||||
device_count = RelatedObjectCountField('devices')
|
||||
prefix_count = RelatedObjectCountField('prefixes')
|
||||
rack_count = RelatedObjectCountField('racks')
|
||||
vlan_count = RelatedObjectCountField('vlans')
|
||||
virtualmachine_count = RelatedObjectCountField('virtual_machines')
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
|
@ -159,6 +161,7 @@ class SiteSerializer(NetBoxModelSerializer):
|
|||
'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
|
||||
'virtualmachine_count', 'vlan_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
|
||||
|
||||
|
||||
#
|
||||
|
@ -180,11 +183,14 @@ class LocationSerializer(NestedGroupModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
|
||||
|
||||
|
||||
class RackRoleSerializer(NetBoxModelSerializer):
|
||||
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:
|
||||
model = RackRole
|
||||
|
@ -192,6 +198,7 @@ class RackRoleSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'rack_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
|
||||
|
||||
|
||||
class RackSerializer(NetBoxModelSerializer):
|
||||
|
@ -207,8 +214,10 @@ class RackSerializer(NetBoxModelSerializer):
|
|||
width = ChoiceField(choices=RackWidthChoices, required=False)
|
||||
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True)
|
||||
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
|
||||
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:
|
||||
model = Rack
|
||||
|
@ -218,6 +227,7 @@ class RackSerializer(NetBoxModelSerializer):
|
|||
'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
|
||||
|
||||
|
||||
class RackUnitSerializer(serializers.Serializer):
|
||||
|
@ -252,6 +262,7 @@ class RackReservationSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
|
||||
'comments', 'tags', 'custom_fields',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
|
||||
|
||||
|
||||
class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
|
@ -299,9 +310,11 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
|||
|
||||
class ManufacturerSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
|
||||
devicetype_count = serializers.IntegerField(read_only=True)
|
||||
inventoryitem_count = serializers.IntegerField(read_only=True)
|
||||
platform_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
# Related object counts
|
||||
devicetype_count = RelatedObjectCountField('device_types')
|
||||
inventoryitem_count = RelatedObjectCountField('inventory_items')
|
||||
platform_count = RelatedObjectCountField('platforms')
|
||||
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
|
@ -309,6 +322,7 @@ class ManufacturerSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'devicetype_count', 'inventoryitem_count', 'platform_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
|
||||
|
||||
|
||||
class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||
|
@ -325,7 +339,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
|||
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
|
||||
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
|
||||
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
|
||||
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)
|
||||
inventory_item_template_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
# Related object counts
|
||||
device_count = RelatedObjectCountField('instances')
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
|
@ -350,6 +368,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
|||
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
|
||||
'inventory_item_template_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
|
||||
|
||||
|
||||
class ModuleTypeSerializer(NetBoxModelSerializer):
|
||||
|
@ -363,6 +382,7 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
|
||||
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -393,6 +413,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
@ -419,6 +440,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
@ -446,6 +468,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
|
||||
'allocated_draw', 'description', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
|
@ -483,6 +506,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
|
||||
'description', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
||||
|
@ -527,6 +551,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
|
||||
'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class RearPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
@ -549,6 +574,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
|
||||
'description', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class FrontPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
@ -572,6 +598,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
|
||||
'rear_port_position', 'description', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class ModuleBayTemplateSerializer(ValidatedModelSerializer):
|
||||
|
@ -584,6 +611,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
|
||||
|
@ -593,6 +621,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
|
|||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class InventoryItemTemplateSerializer(ValidatedModelSerializer):
|
||||
|
@ -619,6 +648,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
|
||||
'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_component(self, obj):
|
||||
|
@ -636,8 +666,10 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
|
|||
class DeviceRoleSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
|
||||
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:
|
||||
model = DeviceRole
|
||||
|
@ -645,14 +677,17 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
|
||||
|
||||
|
||||
class PlatformSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
|
||||
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
|
||||
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
|
||||
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:
|
||||
model = Platform
|
||||
|
@ -660,6 +695,7 @@ class PlatformSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
|
||||
|
||||
|
||||
class DeviceSerializer(NetBoxModelSerializer):
|
||||
|
@ -716,6 +752,7 @@ class DeviceSerializer(NetBoxModelSerializer):
|
|||
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
|
||||
'device_bay_count', 'module_bay_count', 'inventory_item_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
@extend_schema_field(NestedDeviceSerializer)
|
||||
def get_parent_device(self, obj):
|
||||
|
@ -761,7 +798,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
|||
status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
|
||||
|
||||
# Related object counts
|
||||
interface_count = serializers.IntegerField(read_only=True)
|
||||
interface_count = RelatedObjectCountField('interfaces')
|
||||
|
||||
class Meta:
|
||||
model = VirtualDeviceContext
|
||||
|
@ -770,6 +807,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
|||
'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'interface_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
|
||||
|
||||
|
||||
class ModuleSerializer(NetBoxModelSerializer):
|
||||
|
@ -785,6 +823,7 @@ class ModuleSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
|
||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -817,6 +856,7 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
|
|||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
|
@ -845,6 +885,7 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
|
|||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
|
@ -879,6 +920,7 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
|
|||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
|
@ -903,6 +945,7 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
|
@ -965,6 +1008,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
|
@ -996,6 +1040,7 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
|||
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
|
@ -1026,6 +1071,7 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
|||
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
|
||||
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class ModuleBaySerializer(NetBoxModelSerializer):
|
||||
|
@ -1037,9 +1083,9 @@ class ModuleBaySerializer(NetBoxModelSerializer):
|
|||
model = ModuleBay
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
|
||||
'custom_fields',
|
||||
'created', 'last_updated',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
|
||||
|
||||
|
||||
class DeviceBaySerializer(NetBoxModelSerializer):
|
||||
|
@ -1053,6 +1099,7 @@ class DeviceBaySerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
|
||||
|
||||
|
||||
class InventoryItemSerializer(NetBoxModelSerializer):
|
||||
|
@ -1076,6 +1123,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
|
|||
'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_component(self, obj):
|
||||
|
@ -1092,7 +1140,9 @@ class InventoryItemSerializer(NetBoxModelSerializer):
|
|||
|
||||
class InventoryItemRoleSerializer(NetBoxModelSerializer):
|
||||
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:
|
||||
model = InventoryItemRole
|
||||
|
@ -1100,6 +1150,7 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'inventoryitem_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')
|
||||
|
||||
|
||||
#
|
||||
|
@ -1120,6 +1171,7 @@ class CableSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'label', 'description')
|
||||
|
||||
|
||||
class TracedCableSerializer(serializers.ModelSerializer):
|
||||
|
@ -1190,6 +1242,7 @@ class VirtualChassisSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'member_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')
|
||||
|
||||
|
||||
#
|
||||
|
@ -1204,7 +1257,9 @@ class PowerPanelSerializer(NetBoxModelSerializer):
|
|||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
powerfeed_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
# Related object counts
|
||||
powerfeed_count = RelatedObjectCountField('powerfeeds')
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
|
@ -1212,6 +1267,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'powerfeed_count', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
|
||||
|
||||
|
||||
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
|
@ -1251,3 +1307,4 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
|
||||
'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')
|
||||
|
|
|
@ -13,7 +13,6 @@ from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
|||
from dcim.models import *
|
||||
from dcim.svg import CableTraceSVG
|
||||
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
|
||||
from ipam.models import Prefix, VLAN
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
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.query_functions import CollateAsChar
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
|
||||
|
@ -129,14 +127,7 @@ class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class SiteViewSet(NetBoxModelViewSet):
|
||||
queryset = Site.objects.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')
|
||||
)
|
||||
queryset = Site.objects.all()
|
||||
serializer_class = serializers.SiteSerializer
|
||||
filterset_class = filtersets.SiteFilterSet
|
||||
|
||||
|
@ -168,9 +159,7 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class RackRoleViewSet(NetBoxModelViewSet):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
queryset = RackRole.objects.all()
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
filterset_class = filtersets.RackRoleFilterSet
|
||||
|
||||
|
@ -180,13 +169,16 @@ class RackRoleViewSet(NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class RackViewSet(NetBoxModelViewSet):
|
||||
queryset = Rack.objects.annotate(
|
||||
device_count=count_related(Device, 'rack'),
|
||||
powerfeed_count=count_related(PowerFeed, 'rack')
|
||||
)
|
||||
queryset = Rack.objects.all()
|
||||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filtersets.RackFilterSet
|
||||
|
||||
@extend_schema(
|
||||
operation_id='dcim_racks_elevation_retrieve',
|
||||
filters=False,
|
||||
parameters=[serializers.RackElevationDetailFilterSerializer],
|
||||
responses={200: serializers.RackUnitSerializer(many=True)}
|
||||
)
|
||||
@action(detail=True)
|
||||
def elevation(self, request, pk=None):
|
||||
"""
|
||||
|
@ -255,11 +247,7 @@ class RackReservationViewSet(NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class ManufacturerViewSet(NetBoxModelViewSet):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
platform_count=count_related(Platform, 'manufacturer')
|
||||
)
|
||||
queryset = Manufacturer.objects.all()
|
||||
serializer_class = serializers.ManufacturerSerializer
|
||||
filterset_class = filtersets.ManufacturerFilterSet
|
||||
|
||||
|
@ -269,9 +257,7 @@ class ManufacturerViewSet(NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class DeviceTypeViewSet(NetBoxModelViewSet):
|
||||
queryset = DeviceType.objects.annotate(
|
||||
device_count=count_related(Device, 'device_type')
|
||||
)
|
||||
queryset = DeviceType.objects.all()
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
filterset_class = filtersets.DeviceTypeFilterSet
|
||||
|
||||
|
@ -351,10 +337,7 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class DeviceRoleViewSet(NetBoxModelViewSet):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=count_related(Device, 'role'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
queryset = DeviceRole.objects.all()
|
||||
serializer_class = serializers.DeviceRoleSerializer
|
||||
filterset_class = filtersets.DeviceRoleFilterSet
|
||||
|
||||
|
@ -364,10 +347,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class PlatformViewSet(NetBoxModelViewSet):
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=count_related(Device, 'platform'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'platform')
|
||||
)
|
||||
queryset = Platform.objects.all()
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
filterset_class = filtersets.PlatformFilterSet
|
||||
|
||||
|
@ -398,21 +378,15 @@ class DeviceViewSet(
|
|||
|
||||
Else, return the DeviceWithConfigContextSerializer
|
||||
"""
|
||||
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief', False):
|
||||
return serializers.NestedDeviceSerializer
|
||||
|
||||
elif 'config_context' in request.query_params.get('exclude', []):
|
||||
if self.brief or 'config_context' in request.query_params.get('exclude', []):
|
||||
return serializers.DeviceSerializer
|
||||
|
||||
return serializers.DeviceWithConfigContextSerializer
|
||||
|
||||
|
||||
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
|
||||
queryset = VirtualDeviceContext.objects.annotate(
|
||||
interface_count=count_related(Interface, 'vdcs'),
|
||||
)
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
serializer_class = serializers.VirtualDeviceContextSerializer
|
||||
filterset_class = filtersets.VirtualDeviceContextFilterSet
|
||||
|
||||
|
@ -513,9 +487,7 @@ class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class InventoryItemRoleViewSet(NetBoxModelViewSet):
|
||||
queryset = InventoryItemRole.objects.annotate(
|
||||
inventoryitem_count=count_related(InventoryItem, 'role')
|
||||
)
|
||||
queryset = InventoryItemRole.objects.all()
|
||||
serializer_class = serializers.InventoryItemRoleSerializer
|
||||
filterset_class = filtersets.InventoryItemRoleFilterSet
|
||||
|
||||
|
@ -552,9 +524,7 @@ class VirtualChassisViewSet(NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class PowerPanelViewSet(NetBoxModelViewSet):
|
||||
queryset = PowerPanel.objects.annotate(
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
queryset = PowerPanel.objects.all()
|
||||
serializer_class = serializers.PowerPanelSerializer
|
||||
filterset_class = filtersets.PowerPanelFilterSet
|
||||
|
||||
|
|
|
@ -8,9 +8,13 @@ class DCIMConfig(AppConfig):
|
|||
verbose_name = "DCIM"
|
||||
|
||||
def ready(self):
|
||||
from netbox.models.features import register_models
|
||||
from utilities.counters import connect_counters
|
||||
from . import signals, search
|
||||
from .models import CableTermination, Device, DeviceType, VirtualChassis
|
||||
from utilities.counters import connect_counters
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
||||
# Register denormalized fields
|
||||
denormalized.register(CableTermination, '_device', {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
|
||||
|
||||
from .lookups import PathContains
|
||||
|
@ -41,7 +42,7 @@ class MACAddressField(models.Field):
|
|||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
raise ValidationError(f"Invalid MAC address format: {value}")
|
||||
raise ValidationError(_("Invalid MAC address format: {value}").format(value=value))
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'macaddr'
|
||||
|
@ -67,7 +68,7 @@ class WWNField(models.Field):
|
|||
try:
|
||||
return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
raise ValidationError(f"Invalid WWN format: {value}")
|
||||
raise ValidationError(_("Invalid WWN format: {value}").format(value=value))
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'macaddr8'
|
||||
|
|
|
@ -2,6 +2,8 @@ import django_filters
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
|
||||
from circuits.models import CircuitTermination
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
|
@ -818,6 +820,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
|||
to_field_name='slug',
|
||||
label=_('Manufacturer (slug)'),
|
||||
)
|
||||
available_for_device_type = django_filters.ModelChoiceFilter(
|
||||
queryset=DeviceType.objects.all(),
|
||||
method='get_for_device_type'
|
||||
)
|
||||
config_template_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
label=_('Config template (ID)'),
|
||||
|
@ -827,6 +833,14 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
|||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_for_device_type(self, queryset, name, value):
|
||||
"""
|
||||
Return all Platforms available for a specific manufacturer based on device type and Platforms not assigned any
|
||||
manufacturer
|
||||
"""
|
||||
return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
|
||||
|
||||
|
||||
class DeviceFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
|
|
|
@ -159,6 +159,14 @@ class LocationImportForm(NetBoxModelImportForm):
|
|||
model = Location
|
||||
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
# Limit location queryset by assigned site
|
||||
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
|
||||
|
||||
class RackRoleImportForm(NetBoxModelImportForm):
|
||||
slug = SlugField()
|
||||
|
@ -870,7 +878,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||
def clean_vdcs(self):
|
||||
for vdc in self.cleaned_data['vdcs']:
|
||||
if vdc.device != self.cleaned_data['device']:
|
||||
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
|
||||
raise forms.ValidationError(
|
||||
_("VDC {vdc} is not assigned to device {device}").format(
|
||||
vdc=vdc, device=self.cleaned_data['device']
|
||||
)
|
||||
)
|
||||
return self.cleaned_data['vdcs']
|
||||
|
||||
|
||||
|
@ -996,7 +1008,7 @@ class DeviceBayImportForm(NetBoxModelImportForm):
|
|||
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
|
||||
).exclude(pk=device.pk)
|
||||
else:
|
||||
self.fields['installed_device'].queryset = Interface.objects.none()
|
||||
self.fields['installed_device'].queryset = Device.objects.none()
|
||||
|
||||
|
||||
class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
|
@ -1075,7 +1087,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
|||
component = model.objects.get(device=device, name=component_name)
|
||||
self.instance.component = component
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Component not found: {device} - {component_name}")
|
||||
raise forms.ValidationError(
|
||||
_("Component not found: {device} - {component_name}").format(
|
||||
device=device, component_name=component_name
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
|
@ -1193,10 +1209,17 @@ class CableImportForm(NetBoxModelImportForm):
|
|||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None and termination_object.cable != self.instance:
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
raise forms.ValidationError(
|
||||
_("Side {side_upper}: {device} {termination_object} is already connected").format(
|
||||
side_upper=side.upper(), device=device, termination_object=termination_object
|
||||
)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
raise forms.ValidationError(
|
||||
_("{side_upper} side termination not found: {device} {name}").format(
|
||||
side_upper=side.upper(), device=device, name=name
|
||||
)
|
||||
)
|
||||
setattr(self.instance, f'{side}_terminations', [termination_object])
|
||||
return termination_object
|
||||
|
||||
|
|
|
@ -291,7 +291,11 @@ class DeviceTypeForm(NetBoxModelForm):
|
|||
default_platform = DynamicModelChoiceField(
|
||||
label=_('Default platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True,
|
||||
query_params={
|
||||
'manufacturer_id': ['$manufacturer', 'null'],
|
||||
}
|
||||
)
|
||||
slug = SlugField(
|
||||
label=_('Slug'),
|
||||
|
@ -447,7 +451,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
selector=True
|
||||
selector=True,
|
||||
query_params={
|
||||
'available_for_device_type': '$device_type',
|
||||
}
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
|
|
|
@ -233,7 +233,7 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
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(
|
||||
model_name='powerfeed',
|
||||
|
|
|
@ -160,25 +160,26 @@ class Cable(PrimaryModel):
|
|||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
raise ValidationError(_("Must specify a unit when setting a cable length"))
|
||||
|
||||
if self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||
raise ValidationError("Must define A and B terminations when creating a new cable.")
|
||||
raise ValidationError(_("Must define A and B terminations when creating a new cable."))
|
||||
|
||||
if self._terminations_modified:
|
||||
|
||||
# Check that all termination objects for either end are of the same type
|
||||
for terms in (self.a_terminations, self.b_terminations):
|
||||
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
|
||||
raise ValidationError("Cannot connect different termination types to same end of cable.")
|
||||
raise ValidationError(_("Cannot connect different termination types to same end of cable."))
|
||||
|
||||
# Check that termination types are compatible
|
||||
if self.a_terminations and self.b_terminations:
|
||||
a_type = self.a_terminations[0]._meta.model_name
|
||||
b_type = self.b_terminations[0]._meta.model_name
|
||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||
|
||||
raise ValidationError(
|
||||
_("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type)
|
||||
)
|
||||
if a_type == b_type:
|
||||
# can't directly use self.a_terminations here as possible they
|
||||
# don't have pk yet
|
||||
|
@ -327,17 +328,24 @@ class CableTermination(ChangeLoggedModel):
|
|||
existing_termination = qs.first()
|
||||
if existing_termination is not None:
|
||||
raise ValidationError(
|
||||
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
|
||||
f"{self.termination_id}: cable {existing_termination.cable.pk}"
|
||||
_("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}".format(
|
||||
app_label=self.termination_type.app_label,
|
||||
model=self.termination_type.model,
|
||||
termination_id=self.termination_id,
|
||||
cable_pk=existing_termination.cable.pk
|
||||
))
|
||||
)
|
||||
|
||||
# Validate interface type (if applicable)
|
||||
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
|
||||
raise ValidationError(
|
||||
_("Cables cannot be terminated to {type_display} interfaces").format(
|
||||
type_display=self.termination.get_type_display()
|
||||
)
|
||||
)
|
||||
|
||||
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
|
||||
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
|
||||
raise ValidationError("Circuit terminations attached to a provider network may not be cabled.")
|
||||
raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
|
|
|
@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
|
|||
super().clean()
|
||||
|
||||
# Validate that the parent Device can have DeviceBays
|
||||
if not self.device.device_type.is_parent_device:
|
||||
if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
|
||||
raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
|
||||
device_type=self.device.device_type
|
||||
))
|
||||
|
||||
# Cannot install a device into itself, obviously
|
||||
if self.device == self.installed_device:
|
||||
if self.installed_device and getattr(self, 'device', None) == self.installed_device:
|
||||
raise ValidationError(_("Cannot install a device into itself."))
|
||||
|
||||
# Check that the installed device is not already installed elsewhere
|
||||
|
|
|
@ -875,7 +875,7 @@ class Device(
|
|||
if self.position and self.device_type.u_height == 0:
|
||||
raise ValidationError({
|
||||
'position': _(
|
||||
"A U0 device type ({device_type}) cannot be assigned to a rack position."
|
||||
"A 0U device type ({device_type}) cannot be assigned to a rack position."
|
||||
).format(device_type=self.device_type)
|
||||
})
|
||||
|
||||
|
|
|
@ -84,6 +84,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
|||
rack = models.ForeignKey(
|
||||
to='Rack',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='powerfeeds',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
|
|
@ -359,6 +359,11 @@ class CableTerminationTable(NetBoxTable):
|
|||
verbose_name=_('Mark Connected'),
|
||||
)
|
||||
|
||||
def value_link_peer(self, value):
|
||||
return ', '.join([
|
||||
f"{termination.parent_object} > {termination}" for termination in value
|
||||
])
|
||||
|
||||
|
||||
class PathEndpointTable(CableTerminationTable):
|
||||
connection = columns.TemplateColumn(
|
||||
|
|
|
@ -36,7 +36,7 @@ DEVICEBAY_STATUS = """
|
|||
|
||||
INTERFACE_IPADDRESSES = """
|
||||
{% 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 %}
|
||||
{% for ip in value.all %}
|
||||
{% if ip.status != 'active' %}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.choices import *
|
||||
|
@ -45,7 +46,7 @@ class Mixins:
|
|||
name='Peer Device'
|
||||
)
|
||||
if self.peer_termination_type is None:
|
||||
raise NotImplementedError("Test case must set peer_termination_type")
|
||||
raise NotImplementedError(_("Test case must set peer_termination_type"))
|
||||
peer_obj = self.peer_termination_type.objects.create(
|
||||
device=peer_device,
|
||||
name='Peer Termination'
|
||||
|
@ -67,7 +68,7 @@ class Mixins:
|
|||
|
||||
class RegionTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Region
|
||||
brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url']
|
||||
brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Region 4',
|
||||
|
@ -96,7 +97,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class SiteGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = SiteGroup
|
||||
brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url']
|
||||
brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Site Group 4',
|
||||
|
@ -125,7 +126,7 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class SiteTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Site
|
||||
brief_fields = ['display', 'id', 'name', 'slug', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
@ -187,7 +188,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class LocationTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Location
|
||||
brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
|
||||
brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -237,7 +238,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class RackRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RackRole
|
||||
brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Rack Role 4',
|
||||
|
@ -272,7 +273,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Rack
|
||||
brief_fields = ['device_count', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
@ -360,7 +361,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RackReservation
|
||||
brief_fields = ['display', 'id', 'units', 'url', 'user']
|
||||
brief_fields = ['description', 'display', 'id', 'units', 'url', 'user']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -407,7 +408,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Manufacturer
|
||||
brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url']
|
||||
brief_fields = ['description', 'devicetype_count', 'display', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Manufacturer 4',
|
||||
|
@ -439,7 +440,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceType
|
||||
brief_fields = ['device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
|
||||
brief_fields = ['description', 'device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'part_number': 'ABC123',
|
||||
}
|
||||
|
@ -484,7 +485,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ModuleType
|
||||
brief_fields = ['display', 'id', 'manufacturer', 'model', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url']
|
||||
bulk_update_data = {
|
||||
'part_number': 'ABC123',
|
||||
}
|
||||
|
@ -523,7 +524,7 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConsolePortTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -567,7 +568,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConsoleServerPortTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -611,7 +612,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = PowerPortTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -655,7 +656,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = PowerOutletTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -712,7 +713,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = InterfaceTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -760,7 +761,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FrontPortTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -849,7 +850,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RearPortTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -897,7 +898,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ModuleBayTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -937,7 +938,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceBayTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -977,7 +978,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = InventoryItemTemplate
|
||||
brief_fields = ['_depth', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1028,7 +1029,7 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceRole
|
||||
brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
|
||||
brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Device Role 4',
|
||||
|
@ -1063,7 +1064,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class PlatformTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Platform
|
||||
brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
|
||||
brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Platform 4',
|
||||
|
@ -1095,7 +1096,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Device
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'failed',
|
||||
}
|
||||
|
@ -1285,7 +1286,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Module
|
||||
brief_fields = ['device', 'display', 'id', 'module_bay', 'module_type', 'url']
|
||||
brief_fields = ['description', 'device', 'display', 'id', 'module_bay', 'module_type', 'url']
|
||||
bulk_update_data = {
|
||||
'serial': '1234ABCD',
|
||||
}
|
||||
|
@ -1349,7 +1350,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = ConsolePort
|
||||
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1391,7 +1392,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
|||
|
||||
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = ConsoleServerPort
|
||||
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1433,7 +1434,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
|
|||
|
||||
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = PowerPort
|
||||
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1472,7 +1473,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
|||
|
||||
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = PowerOutlet
|
||||
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1520,7 +1521,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
|||
|
||||
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = Interface
|
||||
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1654,7 +1655,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
|||
|
||||
class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FrontPort
|
||||
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1712,7 +1713,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RearPort
|
||||
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1754,7 +1755,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ModuleBay
|
||||
brief_fields = ['display', 'id', 'module', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'installed_module', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1793,7 +1794,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceBay
|
||||
brief_fields = ['device', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1856,7 +1857,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class InventoryItemTest(APIViewTestCases.APIViewTestCase):
|
||||
model = InventoryItem
|
||||
brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_depth', 'description', 'device', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1916,7 +1917,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = InventoryItemRole
|
||||
brief_fields = ['display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Inventory Item Role 4',
|
||||
|
@ -1951,7 +1952,7 @@ class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class CableTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Cable
|
||||
brief_fields = ['display', 'id', 'label', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'label', 'url']
|
||||
bulk_update_data = {
|
||||
'length': 100,
|
||||
'length_unit': 'm',
|
||||
|
@ -2074,7 +2075,7 @@ class ConnectedDeviceTest(APITestCase):
|
|||
|
||||
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VirtualChassis
|
||||
brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'master', 'member_count', 'name', 'url']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
@ -2155,7 +2156,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class PowerPanelTest(APIViewTestCases.APIViewTestCase):
|
||||
model = PowerPanel
|
||||
brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'powerfeed_count', 'url']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
@ -2204,7 +2205,7 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class PowerFeedTest(APIViewTestCases.APIViewTestCase):
|
||||
model = PowerFeed
|
||||
brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
@ -2259,7 +2260,7 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VirtualDeviceContext
|
||||
brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url']
|
||||
brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
|
|
@ -1787,6 +1787,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='foobar1'),
|
||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='foobar2'),
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
|
||||
Platform(name='Platform 4', slug='platform-4'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
|
||||
|
@ -1813,6 +1814,17 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_available_for_device_type(self):
|
||||
manufacturers = Manufacturer.objects.all()[:2]
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturers[0],
|
||||
model='Device Type 1',
|
||||
slug='device-type-1',
|
||||
u_height=1
|
||||
)
|
||||
params = {'available_for_device_type': device_type.pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Device.objects.all()
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework.fields import Field
|
||||
|
@ -88,7 +89,7 @@ class CustomFieldsDataField(Field):
|
|||
if serializer.is_valid():
|
||||
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
|
||||
else:
|
||||
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
|
||||
raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
|
||||
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
if self.parent.instance:
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import ListField
|
||||
|
||||
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
|
||||
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.models import *
|
||||
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.features import TaggableModelSerializer
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
|
@ -44,9 +44,6 @@ __all__ = (
|
|||
'ImageAttachmentSerializer',
|
||||
'JournalEntrySerializer',
|
||||
'ObjectChangeSerializer',
|
||||
'ReportDetailSerializer',
|
||||
'ReportSerializer',
|
||||
'ReportInputSerializer',
|
||||
'SavedFilterSerializer',
|
||||
'ScriptDetailSerializer',
|
||||
'ScriptInputSerializer',
|
||||
|
@ -79,15 +76,16 @@ class EventRuleSerializer(NetBoxModelSerializer):
|
|||
'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
|
||||
'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
@extend_schema_field(OpenApiTypes.OBJECT)
|
||||
def get_action_object(self, instance):
|
||||
context = {'request': self.context['request']}
|
||||
# We need to manually instantiate the serializer for scripts
|
||||
if instance.action_type == EventRuleActionChoices.SCRIPT:
|
||||
script_name = instance.action_parameters['script_name']
|
||||
script = instance.action_object.scripts[script_name]()
|
||||
return NestedScriptSerializer(script, context=context).data
|
||||
script = instance.action_object
|
||||
instance = script.python_class() if script.python_class else None
|
||||
return NestedScriptSerializer(instance, context=context).data
|
||||
else:
|
||||
serializer = get_serializer_for_model(
|
||||
model=instance.action_object_type.model_class(),
|
||||
|
@ -110,6 +108,7 @@ class WebhookSerializer(NetBoxModelSerializer):
|
|||
'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields',
|
||||
'tags', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -145,10 +144,11 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
def validate_type(self, value):
|
||||
if self.instance and self.instance.type != value:
|
||||
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
|
||||
raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
|
||||
|
||||
return value
|
||||
|
||||
|
@ -187,6 +187,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
|
||||
'choices_count', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')
|
||||
|
||||
|
||||
#
|
||||
|
@ -206,6 +207,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
|
||||
'button_class', 'new_window', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name')
|
||||
|
||||
|
||||
#
|
||||
|
@ -232,6 +234,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
|
|||
'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -251,6 +254,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
|
||||
'shared', 'parameters', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -270,6 +274,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
|
|||
fields = [
|
||||
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'object_id', 'object_type')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_object(self, instance):
|
||||
|
@ -288,7 +293,9 @@ class TagSerializer(ValidatedModelSerializer):
|
|||
many=True,
|
||||
required=False
|
||||
)
|
||||
tagged_items = serializers.IntegerField(read_only=True)
|
||||
|
||||
# Related object counts
|
||||
tagged_items = RelatedObjectCountField('extras_taggeditem_items')
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
|
@ -296,6 +303,7 @@ class TagSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -315,6 +323,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
|
||||
'image_width', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'image')
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
|
@ -364,6 +373,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
|
||||
'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'created')
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
|
@ -487,6 +497,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
|||
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -508,81 +519,58 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
|
|||
'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
|
||||
'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
|
||||
class ReportSerializer(serializers.Serializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras-api:report-detail',
|
||||
lookup_field='full_name',
|
||||
lookup_url_kwarg='pk'
|
||||
)
|
||||
id = serializers.CharField(read_only=True, source="full_name")
|
||||
module = serializers.CharField(max_length=255)
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(max_length=255, required=False)
|
||||
test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
|
||||
result = NestedJobSerializer()
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
||||
|
||||
|
||||
class ReportDetailSerializer(ReportSerializer):
|
||||
result = JobSerializer()
|
||||
|
||||
|
||||
class ReportInputSerializer(serializers.Serializer):
|
||||
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
interval = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['report'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this report.")
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
if value and not self.context['report'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this report.")
|
||||
return value
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptSerializer(serializers.Serializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras-api:script-detail',
|
||||
lookup_field='full_name',
|
||||
lookup_url_kwarg='pk'
|
||||
)
|
||||
id = serializers.CharField(read_only=True, source="full_name")
|
||||
module = serializers.CharField(max_length=255)
|
||||
name = serializers.CharField(read_only=True)
|
||||
description = serializers.CharField(read_only=True)
|
||||
class ScriptSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
|
||||
description = serializers.SerializerMethodField(read_only=True)
|
||||
vars = serializers.SerializerMethodField(read_only=True)
|
||||
result = NestedJobSerializer()
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
result = NestedJobSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Script
|
||||
fields = [
|
||||
'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_vars(self, instance):
|
||||
return {
|
||||
k: v.__class__.__name__ for k, v in instance._get_vars().items()
|
||||
}
|
||||
def get_vars(self, obj):
|
||||
if obj.python_class:
|
||||
return {
|
||||
k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
|
||||
}
|
||||
else:
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_description(self, obj):
|
||||
if obj.python_class:
|
||||
return obj.python_class().description
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class ScriptDetailSerializer(ScriptSerializer):
|
||||
result = JobSerializer()
|
||||
result = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@extend_schema_field(JobSerializer())
|
||||
def get_result(self, obj):
|
||||
job = obj.jobs.all().order_by('-created').first()
|
||||
context = {
|
||||
'request': self.context['request']
|
||||
}
|
||||
data = JobSerializer(job, context=context).data
|
||||
return data
|
||||
|
||||
|
||||
class ScriptInputSerializer(serializers.Serializer):
|
||||
|
@ -593,12 +581,12 @@ class ScriptInputSerializer(serializers.Serializer):
|
|||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this script.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this script.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
|
@ -9,21 +8,20 @@ from rest_framework.generics import RetrieveUpdateDestroyAPIView
|
|||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
from rq import Worker
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from extras import filtersets
|
||||
from extras.models import *
|
||||
from extras.scripts import get_module_and_script, run_script
|
||||
from extras.scripts import run_script
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.features import SyncedDataMixin
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.renderers import TextRenderer
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
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 .mixins import ConfigTemplateRenderMixin
|
||||
|
||||
|
@ -147,9 +145,7 @@ class BookmarkViewSet(NetBoxModelViewSet):
|
|||
#
|
||||
|
||||
class TagViewSet(NetBoxModelViewSet):
|
||||
queryset = Tag.objects.annotate(
|
||||
tagged_items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
queryset = Tag.objects.all()
|
||||
serializer_class = serializers.TagSerializer
|
||||
filterset_class = filtersets.TagFilterSet
|
||||
|
||||
|
@ -211,66 +207,30 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
|
|||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptViewSet(ViewSet):
|
||||
class ScriptViewSet(ModelViewSet):
|
||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||
queryset = Script.objects.prefetch_related('jobs')
|
||||
serializer_class = serializers.ScriptSerializer
|
||||
filterset_class = filtersets.ScriptFilterSet
|
||||
|
||||
_ignore_model_permissions = True
|
||||
schema = None
|
||||
lookup_value_regex = '[^/]+' # Allow dots
|
||||
|
||||
def _get_script(self, pk):
|
||||
try:
|
||||
module_name, script_name = pk.split('.', maxsplit=1)
|
||||
except ValueError:
|
||||
raise Http404
|
||||
|
||||
module, script = get_module_and_script(module_name, script_name)
|
||||
if script is None:
|
||||
raise Http404
|
||||
|
||||
return module, script
|
||||
|
||||
def list(self, request):
|
||||
results = {
|
||||
job.name: job
|
||||
for job in Job.objects.filter(
|
||||
object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).order_by('name', '-created').distinct('name').defer('data')
|
||||
}
|
||||
|
||||
script_list = []
|
||||
for script_module in ScriptModule.objects.restrict(request.user):
|
||||
script_list.extend(script_module.scripts.values())
|
||||
|
||||
# Attach Job objects to each script (if any)
|
||||
for script in script_list:
|
||||
script.result = results.get(script.class_name, None)
|
||||
|
||||
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
|
||||
|
||||
return Response({'count': len(script_list), 'results': serializer.data})
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
module, script = self._get_script(pk)
|
||||
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
script.result = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
name=script.class_name,
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
script = get_object_or_404(self.queryset, pk=pk)
|
||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request, pk):
|
||||
"""
|
||||
Run a Script identified as "<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'):
|
||||
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(
|
||||
data=request.data,
|
||||
context={'script': script}
|
||||
|
@ -283,13 +243,13 @@ class ScriptViewSet(ViewSet):
|
|||
if input_serializer.is_valid():
|
||||
script.result = Job.enqueue(
|
||||
run_script,
|
||||
instance=module,
|
||||
name=script.class_name,
|
||||
instance=script.module,
|
||||
name=script.python_class.class_name,
|
||||
user=request.user,
|
||||
data=input_serializer.data['data'],
|
||||
request=copy_safe_request(request),
|
||||
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'),
|
||||
interval=input_serializer.validated_data.get('interval')
|
||||
)
|
||||
|
|
|
@ -5,4 +5,8 @@ class ExtrasConfig(AppConfig):
|
|||
name = "extras"
|
||||
|
||||
def ready(self):
|
||||
from netbox.models.features import register_models
|
||||
from . import dashboard, lookups, search, signals
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import functools
|
||||
import re
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'Condition',
|
||||
|
@ -50,11 +51,13 @@ class Condition:
|
|||
|
||||
def __init__(self, attr, value, op=EQ, negate=False):
|
||||
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:
|
||||
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)]:
|
||||
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.value = value
|
||||
|
@ -131,14 +134,17 @@ class ConditionSet:
|
|||
"""
|
||||
def __init__(self, ruleset):
|
||||
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:
|
||||
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
|
||||
logic = list(ruleset.keys())[0]
|
||||
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()
|
||||
|
||||
# Compile the set of Conditions
|
||||
|
|
|
@ -2,6 +2,7 @@ import uuid
|
|||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from extras.constants import DEFAULT_DASHBOARD
|
||||
|
@ -32,7 +33,7 @@ def get_widget_class(name):
|
|||
try:
|
||||
return registry['widgets'][name]
|
||||
except KeyError:
|
||||
raise ValueError(f"Unregistered widget class: {name}")
|
||||
raise ValueError(_("Unregistered widget class: {name}").format(name=name))
|
||||
|
||||
|
||||
def get_dashboard(user):
|
||||
|
|
|
@ -111,7 +111,9 @@ class DashboardWidget:
|
|||
Params:
|
||||
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
|
||||
def name(self):
|
||||
|
@ -177,7 +179,7 @@ class ObjectCountsWidget(DashboardWidget):
|
|||
try:
|
||||
dict(data)
|
||||
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
|
||||
|
||||
def render(self, request):
|
||||
|
@ -231,7 +233,7 @@ class ObjectListWidget(DashboardWidget):
|
|||
try:
|
||||
urlencode(data)
|
||||
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
|
||||
|
||||
def render(self, request):
|
||||
|
|
|
@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django_rq import get_queue
|
||||
|
||||
from core.models import Job
|
||||
|
@ -115,21 +116,21 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
|
|||
# Scripts
|
||||
elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
|
||||
# Resolve the script from action parameters
|
||||
script_module = event_rule.action_object
|
||||
script_name = event_rule.action_parameters['script_name']
|
||||
script = script_module.scripts[script_name]()
|
||||
script = event_rule.action_object.python_class()
|
||||
|
||||
# Enqueue a Job to record the script's execution
|
||||
Job.enqueue(
|
||||
"extras.scripts.run_script",
|
||||
instance=script_module,
|
||||
name=script.class_name,
|
||||
instance=script.module,
|
||||
name=script.name,
|
||||
user=user,
|
||||
data=data
|
||||
)
|
||||
|
||||
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):
|
||||
|
@ -175,4 +176,4 @@ def flush_events(queue):
|
|||
func = import_string(name)
|
||||
func(queue)
|
||||
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))
|
||||
|
|
|
@ -29,11 +29,32 @@ __all__ = (
|
|||
'LocalConfigContextFilterSet',
|
||||
'ObjectChangeFilterSet',
|
||||
'SavedFilterFilterSet',
|
||||
'ScriptFilterSet',
|
||||
'TagFilterSet',
|
||||
'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):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
|
|
@ -202,7 +202,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
|
|||
try:
|
||||
webhook = Webhook.objects.get(name=action_object)
|
||||
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
|
||||
# Script
|
||||
elif action_type == EventRuleActionChoices.SCRIPT:
|
||||
|
@ -211,12 +211,9 @@ class EventRuleImportForm(NetBoxModelImportForm):
|
|||
try:
|
||||
module, script = get_module_and_script(module_name, script_name)
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Script {action_object} not found")
|
||||
self.instance.action_object = module
|
||||
self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
|
||||
self.instance.action_parameters = {
|
||||
'script_name': script_name,
|
||||
}
|
||||
raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
|
||||
self.instance.action_object = script
|
||||
self.instance.action_object_type = ContentType.objects.get_for_model(script, for_concrete_model=False)
|
||||
|
||||
|
||||
class TagImportForm(CSVModelForm):
|
||||
|
|
|
@ -297,20 +297,16 @@ class EventRuleForm(NetBoxModelForm):
|
|||
}
|
||||
|
||||
def init_script_choice(self):
|
||||
choices = []
|
||||
for module in ScriptModule.objects.all():
|
||||
scripts = []
|
||||
for script_name in module.scripts.keys():
|
||||
name = f"{str(module.pk)}:{script_name}"
|
||||
scripts.append((name, script_name))
|
||||
if scripts:
|
||||
choices.append((str(module), scripts))
|
||||
self.fields['action_choice'].choices = choices
|
||||
|
||||
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}'
|
||||
initial = None
|
||||
if self.instance.action_type == EventRuleActionChoices.SCRIPT:
|
||||
script_id = get_field_value(self, 'action_object_id')
|
||||
initial = Script.objects.get(pk=script_id) if script_id else None
|
||||
self.fields['action_choice'] = DynamicModelChoiceField(
|
||||
label=_('Script'),
|
||||
queryset=Script.objects.all(),
|
||||
required=True,
|
||||
initial=initial
|
||||
)
|
||||
|
||||
def init_webhook_choice(self):
|
||||
initial = None
|
||||
|
@ -348,26 +344,13 @@ class EventRuleForm(NetBoxModelForm):
|
|||
# Script
|
||||
elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
|
||||
self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(
|
||||
ScriptModule,
|
||||
Script,
|
||||
for_concrete_model=False
|
||||
)
|
||||
module_id, script_name = action_choice.split(":", maxsplit=1)
|
||||
self.cleaned_data['action_object_id'] = module_id
|
||||
self.cleaned_data['action_object_id'] = action_choice.id
|
||||
|
||||
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):
|
||||
slug = SlugField()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from netbox.search.backends import search_backend
|
||||
|
@ -62,7 +63,7 @@ class Command(BaseCommand):
|
|||
# Determine which models to reindex
|
||||
indexers = self._get_indexers(*model_labels)
|
||||
if not indexers:
|
||||
raise CommandError("No indexers found!")
|
||||
raise CommandError(_("No indexers found!"))
|
||||
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
||||
|
||||
# Clear all cached values for the specified models (if not being lazy)
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -14,7 +14,7 @@ def convert_reportmodule_jobs(apps, schema_editor):
|
|||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0106_bookmark_user_cascade_deletion'),
|
||||
('extras', '0107_cachedvalue_extras_cachedvalue_object'),
|
||||
]
|
||||
|
||||
operations = [
|
|
@ -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
|
||||
),
|
||||
]
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -115,10 +115,6 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
|
|||
ct_field='action_object_type',
|
||||
fk_field='action_object_id'
|
||||
)
|
||||
action_parameters = models.JSONField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
action_data = models.JSONField(
|
||||
verbose_name=_('data'),
|
||||
blank=True,
|
||||
|
|
|
@ -2,8 +2,11 @@ import inspect
|
|||
import logging
|
||||
from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
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.utils.translation import gettext_lazy as _
|
||||
|
||||
|
@ -22,12 +25,63 @@ __all__ = (
|
|||
logger = logging.getLogger('netbox.data_backends')
|
||||
|
||||
|
||||
class Script(EventRulesMixin, models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
|
||||
"""
|
||||
class Script(EventRulesMixin, JobsMixin):
|
||||
name = models.CharField(
|
||||
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:
|
||||
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)):
|
||||
|
@ -55,7 +109,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
|||
return self.python_name
|
||||
|
||||
@cached_property
|
||||
def scripts(self):
|
||||
def module_scripts(self):
|
||||
|
||||
def _get_name(cls):
|
||||
# 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
|
||||
|
||||
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):
|
||||
self.file_root = ManagedFileRootPathChoices.SCRIPTS
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@receiver(post_save, sender=ScriptModule)
|
||||
def script_module_post_save_handler(instance, created, **kwargs):
|
||||
instance.sync_classes()
|
||||
|
|
|
@ -57,6 +57,9 @@ class CachedValue(models.Model):
|
|||
ordering = ('weight', 'object_type', 'value', 'object_id')
|
||||
verbose_name = _('cached value')
|
||||
verbose_name_plural = _('cached values')
|
||||
indexes = (
|
||||
models.Index(fields=('object_type', 'object_id'), name='extras_cachedvalue_object'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
|
||||
|
|
|
@ -6,6 +6,7 @@ __all__ = (
|
|||
)
|
||||
|
||||
|
||||
# Required by extras/migrations/0109_script_models.py
|
||||
class Report(BaseScript):
|
||||
|
||||
#
|
||||
|
|
|
@ -17,7 +17,7 @@ from django.utils.translation import gettext as _
|
|||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
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 ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
|
@ -411,11 +411,11 @@ class BaseScript:
|
|||
fieldsets.extend(self.fieldsets)
|
||||
else:
|
||||
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
|
||||
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
|
||||
|
||||
|
@ -582,7 +582,7 @@ def is_variable(obj):
|
|||
|
||||
def get_module_and_script(module_name, script_name):
|
||||
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
|
||||
|
||||
|
||||
|
@ -599,8 +599,7 @@ def run_script(data, job, request=None, commit=True, **kwargs):
|
|||
"""
|
||||
job.start()
|
||||
|
||||
module = ScriptModule.objects.get(pk=job.object_id)
|
||||
script = module.scripts.get(job.name)()
|
||||
script = ScriptModel.objects.get(pk=job.object_id).python_class()
|
||||
|
||||
logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
|
||||
logger.info(f"Running script (commit={commit})")
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import importlib
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
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.dispatch import receiver, Signal
|
||||
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.events import process_event_rules
|
||||
from extras.models import EventRule
|
||||
from extras.validators import CustomValidator
|
||||
from extras.validators import run_validators
|
||||
from netbox.config import get_config
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
from netbox.signals import post_clean
|
||||
from utilities.exceptions import AbortRequest
|
||||
from .choices import ObjectChangeActionChoices
|
||||
|
@ -68,7 +69,7 @@ def handle_changed_object(sender, instance, **kwargs):
|
|||
else:
|
||||
return
|
||||
|
||||
# Create/update an ObejctChange record for this change
|
||||
# Create/update an ObjectChange record for this change
|
||||
objectchange = instance.to_objectchange(action)
|
||||
# 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
|
||||
|
@ -108,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
|
|||
"""
|
||||
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
|
||||
request = current_request.get()
|
||||
if request is None:
|
||||
|
@ -122,6 +135,25 @@ def handle_deleted_object(sender, instance, **kwargs):
|
|||
objectchange.request_id = request.id
|
||||
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
|
||||
queue = events_queue.get()
|
||||
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
|
||||
#
|
||||
|
||||
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)
|
||||
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}'
|
||||
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
|
||||
|
||||
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
|
||||
#
|
||||
|
|
|
@ -11,7 +11,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca
|
|||
from extras.choices import *
|
||||
from extras.models import *
|
||||
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
|
||||
|
||||
User = get_user_model()
|
||||
|
@ -29,7 +29,7 @@ class AppTest(APITestCase):
|
|||
|
||||
class WebhookTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Webhook
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Webhook 4',
|
||||
|
@ -71,7 +71,7 @@ class WebhookTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class EventRuleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = EventRule
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'enabled': False,
|
||||
'description': 'New description',
|
||||
|
@ -149,7 +149,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
||||
model = CustomField
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'content_types': ['dcim.site'],
|
||||
|
@ -201,7 +201,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
|
||||
model = CustomFieldChoiceSet
|
||||
brief_fields = ['choices_count', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['choices_count', 'description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Choice Set 4',
|
||||
|
@ -330,7 +330,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class SavedFilterTest(APIViewTestCases.APIViewTestCase):
|
||||
model = SavedFilter
|
||||
brief_fields = ['display', 'id', 'name', 'slug', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'content_types': ['dcim.site'],
|
||||
|
@ -455,7 +455,7 @@ class BookmarkTest(
|
|||
|
||||
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ExportTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'content_types': ['dcim.device'],
|
||||
|
@ -500,7 +500,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class TagTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Tag
|
||||
brief_fields = ['color', 'display', 'id', 'name', 'slug', 'url']
|
||||
brief_fields = ['color', 'description', 'display', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Tag 4',
|
||||
|
@ -627,7 +627,7 @@ class JournalEntryTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigContext
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Config Context 4',
|
||||
|
@ -708,7 +708,7 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Config Template 4',
|
||||
|
@ -748,7 +748,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ScriptTest(APITestCase):
|
||||
|
||||
class TestScript(Script):
|
||||
class TestScriptClass(PythonClass):
|
||||
|
||||
class Meta:
|
||||
name = "Test script"
|
||||
|
@ -767,27 +767,36 @@ class ScriptTest(APITestCase):
|
|||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
ScriptModule.objects.create(
|
||||
module = ScriptModule.objects.create(
|
||||
file_root=ManagedFileRootPathChoices.SCRIPTS,
|
||||
file_path='/var/tmp/script.py'
|
||||
)
|
||||
Script.objects.create(
|
||||
module=module,
|
||||
name="Test script",
|
||||
is_executable=True,
|
||||
)
|
||||
|
||||
def get_test_script(self, *args):
|
||||
return ScriptModule.objects.first(), self.TestScript
|
||||
def python_class(self):
|
||||
return self.TestScriptClass
|
||||
|
||||
def setUp(self):
|
||||
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
|
||||
ScriptViewSet._get_script = self.get_test_script
|
||||
Script.python_class = self.python_class
|
||||
|
||||
def test_get_script(self):
|
||||
|
||||
url = reverse('extras-api:script-detail', kwargs={'pk': None})
|
||||
module = ScriptModule.objects.get(
|
||||
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)
|
||||
|
||||
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']['var2'], 'IntegerVar')
|
||||
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
|
||||
|
|
|
@ -120,10 +120,15 @@ urlpatterns = [
|
|||
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||
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/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
|
||||
path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||
path('scripts/<str:module>/<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'),
|
||||
path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
||||
path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'),
|
||||
path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'),
|
||||
path('scripts/<int:pk>/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
|
||||
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"),
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
from taggit.managers import _TaggableManager
|
||||
|
||||
from netbox.registry import registry
|
||||
|
||||
|
||||
def is_taggable(obj):
|
||||
"""
|
||||
|
@ -29,24 +27,6 @@ def image_upload(instance, 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):
|
||||
"""
|
||||
Returns True if the object is a Script or Report.
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import importlib
|
||||
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -149,3 +151,21 @@ class CustomValidator:
|
|||
if field is not None:
|
||||
raise ValidationError({field: 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)
|
||||
|
|
|
@ -921,7 +921,7 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
|
|||
widget = widget_class(**data)
|
||||
request.user.dashboard.add_widget(widget)
|
||||
request.user.dashboard.save()
|
||||
messages.success(request, f'Added widget {widget.id}')
|
||||
messages.success(request, _('Added widget: ') + str(widget.id))
|
||||
|
||||
return HttpResponse(headers={
|
||||
'HX-Redirect': reverse('home'),
|
||||
|
@ -962,7 +962,7 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View):
|
|||
data['config'] = config_form.cleaned_data
|
||||
request.user.dashboard.config[str(id)].update(data)
|
||||
request.user.dashboard.save()
|
||||
messages.success(request, f'Updated widget {widget.id}')
|
||||
messages.success(request, _('Updated widget: ') + str(widget.id))
|
||||
|
||||
return HttpResponse(headers={
|
||||
'HX-Redirect': reverse('home'),
|
||||
|
@ -998,9 +998,9 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
|
|||
if form.is_valid():
|
||||
request.user.dashboard.delete_widget(id)
|
||||
request.user.dashboard.save()
|
||||
messages.success(request, f'Deleted widget {id}')
|
||||
messages.success(request, _('Deleted widget: ') + str(id))
|
||||
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'))
|
||||
|
||||
|
@ -1031,7 +1031,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
|||
return 'extras.view_script'
|
||||
|
||||
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', {
|
||||
'model': ScriptModule,
|
||||
|
@ -1039,123 +1039,122 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
|||
})
|
||||
|
||||
|
||||
def get_script_module(module, request):
|
||||
return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
|
||||
class ScriptView(generic.ObjectView):
|
||||
queryset = Script.objects.all()
|
||||
|
||||
|
||||
class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
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))
|
||||
def get(self, request, **kwargs):
|
||||
script = self.get_object(**kwargs)
|
||||
script_class = script.python_class()
|
||||
form = script_class.as_form(initial=normalize_querydict(request.GET))
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'script_class': script_class,
|
||||
'form': form,
|
||||
'job_count': script.jobs.count(),
|
||||
})
|
||||
|
||||
def post(self, request, module, name):
|
||||
if not request.user.has_perm('extras.run_script'):
|
||||
def post(self, request, **kwargs):
|
||||
script = self.get_object(**kwargs)
|
||||
script_class = script.python_class()
|
||||
|
||||
if not request.user.has_perm('extras.run_script', obj=script):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
jobs = module.get_jobs(script.class_name)
|
||||
form = script.as_form(request.POST, request.FILES)
|
||||
form = script_class.as_form(request.POST, request.FILES)
|
||||
|
||||
# Allow execution only if RQ worker process is running
|
||||
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():
|
||||
job = Job.enqueue(
|
||||
run_script,
|
||||
instance=module,
|
||||
name=script.class_name,
|
||||
instance=script,
|
||||
name=script_class.class_name,
|
||||
user=request.user,
|
||||
schedule_at=form.cleaned_data.pop('_schedule_at'),
|
||||
interval=form.cleaned_data.pop('_interval'),
|
||||
data=form.cleaned_data,
|
||||
request=copy_safe_request(request),
|
||||
job_timeout=script.job_timeout,
|
||||
job_timeout=script.python_class.job_timeout,
|
||||
commit=form.cleaned_data.pop('_commit')
|
||||
)
|
||||
|
||||
return redirect('extras:script_result', job_pk=job.pk)
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'script_class': script.python_class(),
|
||||
'form': form,
|
||||
'job_count': script.jobs.count(),
|
||||
})
|
||||
|
||||
|
||||
class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
|
||||
class ScriptSourceView(generic.ObjectView):
|
||||
queryset = Script.objects.all()
|
||||
|
||||
def get_required_permission(self):
|
||||
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)
|
||||
def get(self, request, **kwargs):
|
||||
script = self.get_object(**kwargs)
|
||||
|
||||
return render(request, 'extras/script/source.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'script_class': script.python_class(),
|
||||
'job_count': script.jobs.count(),
|
||||
'tab': 'source',
|
||||
})
|
||||
|
||||
|
||||
class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
class ScriptJobsView(generic.ObjectView):
|
||||
queryset = Script.objects.all()
|
||||
|
||||
def get_required_permission(self):
|
||||
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)
|
||||
def get(self, request, **kwargs):
|
||||
script = self.get_object(**kwargs)
|
||||
|
||||
jobs_table = JobTable(
|
||||
data=jobs,
|
||||
data=script.jobs.all(),
|
||||
orderable=False,
|
||||
user=request.user
|
||||
)
|
||||
jobs_table.configure(request)
|
||||
|
||||
return render(request, 'extras/script/jobs.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'table': jobs_table,
|
||||
'job_count': script.jobs.count(),
|
||||
'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):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, job_pk):
|
||||
object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='scriptmodule')
|
||||
job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
|
||||
|
||||
module = job.object
|
||||
script = module.scripts[job.name]()
|
||||
def get(self, request, **kwargs):
|
||||
job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
|
||||
|
||||
context = {
|
||||
'script': script,
|
||||
'script': job.object,
|
||||
'job': job,
|
||||
}
|
||||
if job.data and 'log' in job.data:
|
||||
|
|
|
@ -2,6 +2,7 @@ from drf_spectacular.utils import extend_schema_serializer
|
|||
from rest_framework import serializers
|
||||
|
||||
from ipam import models
|
||||
from netbox.api.fields import RelatedObjectCountField
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
from .field_serializers import IPAddressField
|
||||
|
||||
|
@ -58,7 +59,7 @@ class NestedASNSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedVRFSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
prefix_count = RelatedObjectCountField('prefixes')
|
||||
|
||||
class Meta:
|
||||
model = models.VRF
|
||||
|
@ -86,7 +87,7 @@ class NestedRouteTargetSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedRIRSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
|
||||
aggregate_count = serializers.IntegerField(read_only=True)
|
||||
aggregate_count = RelatedObjectCountField('aggregates')
|
||||
|
||||
class Meta:
|
||||
model = models.RIR
|
||||
|
@ -132,8 +133,8 @@ class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedRoleSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
prefix_count = RelatedObjectCountField('prefixes')
|
||||
vlan_count = RelatedObjectCountField('vlans')
|
||||
|
||||
class Meta:
|
||||
model = models.Role
|
||||
|
@ -145,7 +146,7 @@ class NestedRoleSerializer(WritableNestedSerializer):
|
|||
)
|
||||
class NestedVLANGroupSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
vlan_count = RelatedObjectCountField('vlans')
|
||||
|
||||
class Meta:
|
||||
model = models.VLANGroup
|
||||
|
|
|
@ -6,7 +6,7 @@ from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerial
|
|||
from ipam.choices import *
|
||||
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
|
||||
from ipam.models import *
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
|
@ -33,6 +33,7 @@ class ASNRangeSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'asn_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -43,8 +44,10 @@ class ASNSerializer(NetBoxModelSerializer):
|
|||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
|
||||
rir = NestedRIRSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
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:
|
||||
model = ASN
|
||||
|
@ -52,6 +55,7 @@ class ASNSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'site_count', 'provider_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'asn', 'description')
|
||||
|
||||
|
||||
class AvailableASNSerializer(serializers.Serializer):
|
||||
|
@ -90,8 +94,10 @@ class VRFSerializer(NetBoxModelSerializer):
|
|||
required=False,
|
||||
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:
|
||||
model = VRF
|
||||
|
@ -100,6 +106,7 @@ class VRFSerializer(NetBoxModelSerializer):
|
|||
'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_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',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -124,7 +132,9 @@ class RouteTargetSerializer(NetBoxModelSerializer):
|
|||
|
||||
class RIRSerializer(NetBoxModelSerializer):
|
||||
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:
|
||||
model = RIR
|
||||
|
@ -132,6 +142,7 @@ class RIRSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'aggregate_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'aggregate_count')
|
||||
|
||||
|
||||
class AggregateSerializer(NetBoxModelSerializer):
|
||||
|
@ -147,6 +158,7 @@ class AggregateSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
@ -163,6 +175,7 @@ class FHRPGroupSerializer(NetBoxModelSerializer):
|
|||
'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'protocol', 'group_id', 'description')
|
||||
|
||||
|
||||
class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
|
||||
|
@ -179,6 +192,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_interface(self, obj):
|
||||
|
@ -195,8 +209,10 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
|
|||
|
||||
class RoleSerializer(NetBoxModelSerializer):
|
||||
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:
|
||||
model = Role
|
||||
|
@ -204,6 +220,7 @@ class RoleSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'prefix_count', 'vlan_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count')
|
||||
|
||||
|
||||
class VLANGroupSerializer(NetBoxModelSerializer):
|
||||
|
@ -218,15 +235,18 @@ class VLANGroupSerializer(NetBoxModelSerializer):
|
|||
)
|
||||
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
|
||||
scope = serializers.SerializerMethodField(read_only=True)
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
utilization = serializers.CharField(read_only=True)
|
||||
|
||||
# Related object counts
|
||||
vlan_count = RelatedObjectCountField('vlans')
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
|
||||
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
|
||||
validators = []
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
|
@ -247,7 +267,9 @@ class VLANSerializer(NetBoxModelSerializer):
|
|||
status = ChoiceField(choices=VLANStatusChoices, required=False)
|
||||
role = NestedRoleSerializer(required=False, 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:
|
||||
model = VLAN
|
||||
|
@ -255,6 +277,7 @@ class VLANSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description',
|
||||
'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')
|
||||
|
||||
|
||||
class AvailableVLANSerializer(serializers.Serializer):
|
||||
|
@ -315,6 +338,7 @@ class PrefixSerializer(NetBoxModelSerializer):
|
|||
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
|
||||
'_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
|
||||
|
||||
|
||||
class PrefixLengthSerializer(serializers.Serializer):
|
||||
|
@ -385,6 +409,7 @@ class IPRangeSerializer(NetBoxModelSerializer):
|
|||
'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',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'family', 'address', 'description')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_assigned_object(self, obj):
|
||||
|
@ -457,9 +483,10 @@ class ServiceTemplateSerializer(NetBoxModelSerializer):
|
|||
class Meta:
|
||||
model = ServiceTemplate
|
||||
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',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')
|
||||
|
||||
|
||||
class ServiceSerializer(NetBoxModelSerializer):
|
||||
|
@ -477,6 +504,7 @@ class ServiceSerializer(NetBoxModelSerializer):
|
|||
class Meta:
|
||||
model = Service
|
||||
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',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')
|
||||
|
|
|
@ -3,6 +3,7 @@ from copy import deepcopy
|
|||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from netaddr import IPSet
|
||||
|
@ -12,8 +13,6 @@ from rest_framework.response import Response
|
|||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from circuits.models import Provider
|
||||
from dcim.models import Site
|
||||
from ipam import filtersets
|
||||
from ipam.models import *
|
||||
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.constants import ADVISORY_LOCK_KEYS
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
|
@ -45,19 +43,13 @@ class ASNRangeViewSet(NetBoxModelViewSet):
|
|||
|
||||
|
||||
class ASNViewSet(NetBoxModelViewSet):
|
||||
queryset = ASN.objects.annotate(
|
||||
site_count=count_related(Site, 'asns'),
|
||||
provider_count=count_related(Provider, 'asns')
|
||||
)
|
||||
queryset = ASN.objects.all()
|
||||
serializer_class = serializers.ASNSerializer
|
||||
filterset_class = filtersets.ASNFilterSet
|
||||
|
||||
|
||||
class VRFViewSet(NetBoxModelViewSet):
|
||||
queryset = VRF.objects.annotate(
|
||||
ipaddress_count=count_related(IPAddress, 'vrf'),
|
||||
prefix_count=count_related(Prefix, 'vrf')
|
||||
)
|
||||
queryset = VRF.objects.all()
|
||||
serializer_class = serializers.VRFSerializer
|
||||
filterset_class = filtersets.VRFFilterSet
|
||||
|
||||
|
@ -69,9 +61,7 @@ class RouteTargetViewSet(NetBoxModelViewSet):
|
|||
|
||||
|
||||
class RIRViewSet(NetBoxModelViewSet):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
queryset = RIR.objects.all()
|
||||
serializer_class = serializers.RIRSerializer
|
||||
filterset_class = filtersets.RIRFilterSet
|
||||
|
||||
|
@ -83,10 +73,7 @@ class AggregateViewSet(NetBoxModelViewSet):
|
|||
|
||||
|
||||
class RoleViewSet(NetBoxModelViewSet):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
)
|
||||
queryset = Role.objects.all()
|
||||
serializer_class = serializers.RoleSerializer
|
||||
filterset_class = filtersets.RoleFilterSet
|
||||
|
||||
|
@ -151,8 +138,6 @@ class VLANGroupViewSet(NetBoxModelViewSet):
|
|||
class VLANViewSet(NetBoxModelViewSet):
|
||||
queryset = VLAN.objects.prefetch_related(
|
||||
'l2vpn_terminations', # Referenced by VLANSerializer.l2vpn_termination
|
||||
).annotate(
|
||||
prefix_count=count_related(Prefix, 'vlan')
|
||||
)
|
||||
serializer_class = serializers.VLANSerializer
|
||||
filterset_class = filtersets.VLANFilterSet
|
||||
|
@ -370,7 +355,7 @@ class AvailablePrefixesView(AvailableObjectsView):
|
|||
'vrf': parent.vrf.pk if parent.vrf else None,
|
||||
})
|
||||
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
|
||||
|
||||
|
|
|
@ -6,4 +6,8 @@ class IPAMConfig(AppConfig):
|
|||
verbose_name = "IPAM"
|
||||
|
||||
def ready(self):
|
||||
from netbox.models.features import register_models
|
||||
from . import signals, search
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from netaddr import AddrFormatError, IPNetwork
|
||||
|
||||
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.)
|
||||
return IPNetwork(value)
|
||||
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:
|
||||
raise ValidationError(e)
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
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
|
||||
|
||||
|
||||
|
@ -10,7 +11,7 @@ from netaddr import IPAddress, IPNetwork, AddrFormatError
|
|||
|
||||
class IPAddressFormField(forms.Field):
|
||||
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):
|
||||
|
@ -28,19 +29,19 @@ class IPAddressFormField(forms.Field):
|
|||
try:
|
||||
validate_ipv6_address(value)
|
||||
except ValidationError:
|
||||
raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
|
||||
raise ValidationError(_("Invalid IPv4/IPv6 address format: {address}").format(address=value))
|
||||
|
||||
try:
|
||||
return IPAddress(value)
|
||||
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:
|
||||
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
|
||||
raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
|
||||
|
||||
|
||||
class IPNetworkFormField(forms.Field):
|
||||
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):
|
||||
|
@ -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.
|
||||
if len(value.split('/')) != 2:
|
||||
raise ValidationError('CIDR mask (e.g. /24) is required.')
|
||||
raise ValidationError(_('CIDR mask (e.g. /24) is required.'))
|
||||
|
||||
try:
|
||||
return IPNetwork(value)
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
|
||||
raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
|
||||
|
|
|
@ -756,4 +756,4 @@ class ServiceCreateForm(ServiceForm):
|
|||
if not self.cleaned_data['description']:
|
||||
self.cleaned_data['description'] = service_template.description
|
||||
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."))
|
||||
|
|
|
@ -23,7 +23,7 @@ class AppTest(APITestCase):
|
|||
|
||||
class ASNRangeTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ASNRange
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -135,7 +135,7 @@ class ASNRangeTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ASNTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ASN
|
||||
brief_fields = ['asn', 'display', 'id', 'url']
|
||||
brief_fields = ['asn', 'description', 'display', 'id', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -191,7 +191,7 @@ class ASNTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class VRFTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VRF
|
||||
brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'prefix_count', 'rd', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'VRF 4',
|
||||
|
@ -223,7 +223,7 @@ class VRFTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class RouteTargetTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RouteTarget
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': '65000:1004',
|
||||
|
@ -252,7 +252,7 @@ class RouteTargetTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class RIRTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RIR
|
||||
brief_fields = ['aggregate_count', 'display', 'id', 'name', 'slug', 'url']
|
||||
brief_fields = ['aggregate_count', 'description', 'display', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'RIR 4',
|
||||
|
@ -284,7 +284,7 @@ class RIRTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class AggregateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Aggregate
|
||||
brief_fields = ['display', 'family', 'id', 'prefix', 'url']
|
||||
brief_fields = ['description', 'display', 'family', 'id', 'prefix', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -323,7 +323,7 @@ class AggregateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class RoleTest(APIViewTestCases.APIViewTestCase):
|
||||
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 = [
|
||||
{
|
||||
'name': 'Role 4',
|
||||
|
@ -355,7 +355,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Prefix
|
||||
brief_fields = ['_depth', 'display', 'family', 'id', 'prefix', 'url']
|
||||
brief_fields = ['_depth', 'description', 'display', 'family', 'id', 'prefix', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'prefix': '192.168.4.0/24',
|
||||
|
@ -534,7 +534,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class IPRangeTest(APIViewTestCases.APIViewTestCase):
|
||||
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 = [
|
||||
{
|
||||
'start_address': '192.168.4.10/24',
|
||||
|
@ -633,7 +633,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class IPAddressTest(APIViewTestCases.APIViewTestCase):
|
||||
model = IPAddress
|
||||
brief_fields = ['address', 'display', 'family', 'id', 'url']
|
||||
brief_fields = ['address', 'description', 'display', 'family', 'id', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'address': '192.168.0.4/24',
|
||||
|
@ -718,7 +718,7 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FHRPGroup
|
||||
brief_fields = ['display', 'group_id', 'id', 'protocol', 'url']
|
||||
brief_fields = ['description', 'display', 'group_id', 'id', 'protocol', 'url']
|
||||
bulk_update_data = {
|
||||
'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP,
|
||||
'group_id': 200,
|
||||
|
@ -839,7 +839,7 @@ class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class VLANGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VLANGroup
|
||||
brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url', 'vlan_count']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'VLAN Group 4',
|
||||
|
@ -960,7 +960,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class VLANTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VLAN
|
||||
brief_fields = ['display', 'id', 'name', 'url', 'vid']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url', 'vid']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1020,7 +1020,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ServiceTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -1055,7 +1055,7 @@ class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ServiceTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Service
|
||||
brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
|
|
@ -1,14 +1,19 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import BaseValidator, RegexValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def prefix_validator(prefix):
|
||||
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):
|
||||
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'
|
||||
|
||||
def compare(self, a, b):
|
||||
|
@ -16,7 +21,7 @@ class MaxPrefixLengthValidator(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'
|
||||
|
||||
def compare(self, a, b):
|
||||
|
@ -25,6 +30,6 @@ class MinPrefixLengthValidator(BaseValidator):
|
|||
|
||||
DNSValidator = RegexValidator(
|
||||
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'
|
||||
)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
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.utils import extend_schema_field
|
||||
from netaddr import IPNetwork
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
@ -10,6 +11,7 @@ __all__ = (
|
|||
'ChoiceField',
|
||||
'ContentTypeField',
|
||||
'IPNetworkSerializer',
|
||||
'RelatedObjectCountField',
|
||||
'SerializedPKRelatedField',
|
||||
)
|
||||
|
||||
|
@ -58,11 +60,13 @@ class ChoiceField(serializers.Field):
|
|||
if data == '':
|
||||
if self.allow_blank:
|
||||
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
|
||||
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
|
||||
if hasattr(data, 'lower'):
|
||||
|
@ -82,7 +86,7 @@ class ChoiceField(serializers.Field):
|
|||
except TypeError: # Input is an unhashable type
|
||||
pass
|
||||
|
||||
raise ValidationError(f"{data} is not a valid choice.")
|
||||
raise ValidationError(_("{value} is not a valid choice.").format(value=data))
|
||||
|
||||
@property
|
||||
def choices(self):
|
||||
|
@ -95,8 +99,8 @@ class ContentTypeField(RelatedField):
|
|||
Represent a ContentType as '<app_label>.<model>'
|
||||
"""
|
||||
default_error_messages = {
|
||||
"does_not_exist": "Invalid content type: {content_type}",
|
||||
"invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.",
|
||||
"does_not_exist": _("Invalid content type: {content_type}"),
|
||||
"invalid": _("Invalid value. Specify a content type as '<app_label>.<model_name>'."),
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
|
@ -135,3 +139,16 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField):
|
|||
|
||||
def to_representation(self, value):
|
||||
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)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
@ -30,9 +31,12 @@ class WritableNestedSerializer(BaseModelSerializer):
|
|||
try:
|
||||
return queryset.get(**params)
|
||||
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:
|
||||
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:
|
||||
raise ValidationError(e)
|
||||
|
||||
|
@ -42,15 +46,17 @@ class WritableNestedSerializer(BaseModelSerializer):
|
|||
pk = int(data)
|
||||
except (TypeError, ValueError):
|
||||
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
|
||||
try:
|
||||
return self.Meta.model.objects.get(pk=pk)
|
||||
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
|
||||
|
|
|
@ -10,7 +10,7 @@ from rest_framework import mixins as drf_mixins
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from utilities.api import get_prefetches_for_serializer
|
||||
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
|
||||
from utilities.exceptions import AbortRequest
|
||||
from . import mixins
|
||||
|
||||
|
@ -34,6 +34,8 @@ class BaseViewSet(GenericViewSet):
|
|||
"""
|
||||
Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
|
||||
"""
|
||||
brief = False
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
|
||||
|
@ -42,17 +44,25 @@ class BaseViewSet(GenericViewSet):
|
|||
if action := HTTP_ACTIONS[request.method]:
|
||||
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
|
||||
prefetch = get_prefetches_for_serializer(
|
||||
self.get_serializer_class(),
|
||||
fields_to_include=self.requested_fields
|
||||
)
|
||||
if prefetch:
|
||||
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):
|
||||
|
@ -65,12 +75,17 @@ class BaseViewSet(GenericViewSet):
|
|||
|
||||
@cached_property
|
||||
def requested_fields(self):
|
||||
requested_fields = self.request.query_params.get('fields')
|
||||
return requested_fields.split(',') if requested_fields else []
|
||||
# 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(
|
||||
mixins.BriefModeMixin,
|
||||
mixins.CustomFieldsMixin,
|
||||
mixins.ExportTemplatesMixin,
|
||||
drf_mixins.RetrieveModelMixin,
|
||||
|
@ -84,7 +99,6 @@ class NetBoxModelViewSet(
|
|||
mixins.BulkUpdateModelMixin,
|
||||
mixins.BulkDestroyModelMixin,
|
||||
mixins.ObjectValidationMixin,
|
||||
mixins.BriefModeMixin,
|
||||
mixins.CustomFieldsMixin,
|
||||
mixins.ExportTemplatesMixin,
|
||||
drf_mixins.CreateModelMixin,
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import logging
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
|
@ -8,13 +6,9 @@ from rest_framework import status
|
|||
from rest_framework.response import Response
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
from netbox.api.exceptions import SerializerNotFound
|
||||
from netbox.api.serializers import BulkOperationSerializer
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
|
||||
__all__ = (
|
||||
'BriefModeMixin',
|
||||
'BulkDestroyModelMixin',
|
||||
'BulkUpdateModelMixin',
|
||||
'CustomFieldsMixin',
|
||||
|
@ -24,48 +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
|
||||
|
||||
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)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class CustomFieldsMixin:
|
||||
"""
|
||||
For models which support custom fields, populate the `custom_fields` context.
|
||||
|
|
|
@ -7,6 +7,7 @@ from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _Rem
|
|||
from django.contrib.auth.models import Group, AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from users.constants import CONSTRAINT_TOKEN_USER
|
||||
from users.models import ObjectPermission
|
||||
|
@ -42,6 +43,7 @@ AUTH_BACKEND_ATTRS = {
|
|||
'hubspot': ('HubSpot', 'hubspot'),
|
||||
'keycloak': ('Keycloak', None),
|
||||
'microsoft-graph': ('Microsoft Graph', 'microsoft'),
|
||||
'oidc': ('OpenID Connect', None),
|
||||
'okta': ('Okta', None),
|
||||
'okta-openidconnect': ('Okta (OIDC)', None),
|
||||
'salesforce-oauth2': ('Salesforce', 'salesforce'),
|
||||
|
@ -132,7 +134,9 @@ class ObjectPermissionMixin:
|
|||
# Sanity check: Ensure that the requested permission applies to the specified object
|
||||
model = obj._meta.concrete_model
|
||||
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
|
||||
tokens = {
|
||||
|
|
|
@ -4,6 +4,7 @@ import threading
|
|||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.utils import DatabaseError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .parameters import PARAMS
|
||||
|
||||
|
@ -63,7 +64,7 @@ class Config:
|
|||
if item in self.defaults:
|
||||
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):
|
||||
"""Populate config data from Redis cache"""
|
||||
|
|
|
@ -35,7 +35,9 @@ class CustomFieldsMixin:
|
|||
Return the ContentType of the form's model.
|
||||
"""
|
||||
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)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
|
|
|
@ -5,8 +5,6 @@ from functools import cached_property
|
|||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.validators import ValidationError
|
||||
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.translation import gettext_lazy as _
|
||||
from taggit.managers import TaggableManager
|
||||
|
@ -14,7 +12,7 @@ from taggit.managers import TaggableManager
|
|||
from core.choices import JobStatusChoices
|
||||
from core.models import ContentType
|
||||
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.registry import registry
|
||||
from netbox.signals import post_clean
|
||||
|
@ -37,6 +35,7 @@ __all__ = (
|
|||
'JournalingMixin',
|
||||
'SyncedDataMixin',
|
||||
'TagsMixin',
|
||||
'register_models',
|
||||
)
|
||||
|
||||
|
||||
|
@ -275,16 +274,20 @@ class CustomFieldsMixin(models.Model):
|
|||
# Validate all field values
|
||||
for field_name, value in self.custom_field_data.items():
|
||||
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:
|
||||
custom_fields[field_name].validate(value)
|
||||
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
|
||||
for cf in custom_fields.values():
|
||||
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):
|
||||
|
@ -489,10 +492,10 @@ class SyncedDataMixin(models.Model):
|
|||
# Create/delete AutoSyncRecord as needed
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
if self.auto_sync_enabled:
|
||||
AutoSyncRecord.objects.get_or_create(
|
||||
datafile=self.data_file,
|
||||
AutoSyncRecord.objects.update_or_create(
|
||||
object_type=content_type,
|
||||
object_id=self.pk
|
||||
object_id=self.pk,
|
||||
defaults={'datafile': self.data_file}
|
||||
)
|
||||
else:
|
||||
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
|
||||
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_features(sender, **kwargs):
|
||||
# Record each applicable feature for the model in the registry
|
||||
features = {
|
||||
feature for feature, cls in FEATURES_MAP.items() if issubclass(sender, cls)
|
||||
}
|
||||
register_features(sender, features)
|
||||
def register_models(*models):
|
||||
"""
|
||||
Register one or more models in NetBox. This entails:
|
||||
|
||||
# Register applicable feature views for the model
|
||||
if issubclass(sender, JournalingMixin):
|
||||
register_model_view(
|
||||
sender,
|
||||
'journal',
|
||||
kwargs={'model': sender}
|
||||
)('netbox.views.generic.ObjectJournalView')
|
||||
if issubclass(sender, ChangeLoggingMixin):
|
||||
register_model_view(
|
||||
sender,
|
||||
'changelog',
|
||||
kwargs={'model': sender}
|
||||
)('netbox.views.generic.ObjectChangeLogView')
|
||||
if issubclass(sender, JobsMixin):
|
||||
register_model_view(
|
||||
sender,
|
||||
'jobs',
|
||||
kwargs={'model': sender}
|
||||
)('netbox.views.generic.ObjectJobsView')
|
||||
if issubclass(sender, SyncedDataMixin):
|
||||
register_model_view(
|
||||
sender,
|
||||
'sync',
|
||||
kwargs={'model': sender}
|
||||
)('netbox.views.generic.ObjectSyncDataView')
|
||||
- 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
|
||||
features = {
|
||||
feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls)
|
||||
}
|
||||
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
|
||||
if issubclass(model, JournalingMixin):
|
||||
register_model_view(model, 'journal', kwargs={'model': model})(
|
||||
'netbox.views.generic.ObjectJournalView'
|
||||
)
|
||||
if issubclass(model, ChangeLoggingMixin):
|
||||
register_model_view(model, 'changelog', kwargs={'model': model})(
|
||||
'netbox.views.generic.ObjectChangeLogView'
|
||||
)
|
||||
if issubclass(model, JobsMixin):
|
||||
register_model_view(model, 'jobs', kwargs={'model': model})(
|
||||
'netbox.views.generic.ObjectJobsView'
|
||||
)
|
||||
if issubclass(model, SyncedDataMixin):
|
||||
register_model_view(model, 'sync', kwargs={'model': model})(
|
||||
'netbox.views.generic.ObjectSyncDataView'
|
||||
)
|
||||
|
|
|
@ -94,6 +94,11 @@ class PluginConfig(AppConfig):
|
|||
pass
|
||||
|
||||
def ready(self):
|
||||
from netbox.models.features import register_models
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
||||
plugin_name = self.name.rsplit('.', 1)[-1]
|
||||
|
||||
# Register search extensions (if defined)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from netbox.navigation import MenuGroup
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'PluginMenu',
|
||||
|
@ -42,11 +43,11 @@ class PluginMenuItem:
|
|||
self.staff_only = staff_only
|
||||
if permissions is not None:
|
||||
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
|
||||
if buttons is not None:
|
||||
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
|
||||
|
||||
|
||||
|
@ -64,9 +65,9 @@ class PluginMenuButton:
|
|||
self.icon_class = icon_class
|
||||
if permissions is not None:
|
||||
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
|
||||
if color is not None:
|
||||
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
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import inspect
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from netbox.registry import registry
|
||||
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
|
||||
from .templates import PluginTemplateExtension
|
||||
|
@ -20,18 +21,32 @@ def register_template_extensions(class_list):
|
|||
# Validation
|
||||
for template_extension in class_list:
|
||||
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):
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
def register_menu(menu):
|
||||
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)
|
||||
|
||||
|
||||
|
@ -42,10 +57,14 @@ def register_menu_items(section_name, class_list):
|
|||
# Validation
|
||||
for menu_link in class_list:
|
||||
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:
|
||||
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
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.template.loader import get_template
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'PluginTemplateExtension',
|
||||
|
@ -31,7 +32,7 @@ class PluginTemplateExtension:
|
|||
if extra_context is None:
|
||||
extra_context = {}
|
||||
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})
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import collections
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
class Registry(dict):
|
||||
|
@ -10,13 +11,13 @@ class Registry(dict):
|
|||
try:
|
||||
return super().__getitem__(key)
|
||||
except KeyError:
|
||||
raise KeyError(f"Invalid store: {key}")
|
||||
raise KeyError(_("Invalid store: {key}").format(key=key))
|
||||
|
||||
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):
|
||||
raise TypeError("Cannot delete stores from registry")
|
||||
raise TypeError(_("Cannot delete stores from registry"))
|
||||
|
||||
|
||||
# Initialize the global registry
|
||||
|
|
|
@ -29,7 +29,7 @@ from netbox.plugins import PluginConfig
|
|||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.7.3-dev'
|
||||
VERSION = '4.0.0-dev'
|
||||
|
||||
# Hostname
|
||||
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_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
|
||||
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('/')
|
||||
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
||||
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_user',
|
||||
'social_core.pipeline.user.get_username',
|
||||
'social_core.pipeline.social_auth.associate_by_email',
|
||||
'social_core.pipeline.user.create_user',
|
||||
'social_core.pipeline.social_auth.associate_user',
|
||||
'netbox.authentication.user_default_groups_handler',
|
||||
|
|
|
@ -20,6 +20,10 @@ class PluginTest(TestCase):
|
|||
|
||||
self.assertIn('netbox.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS)
|
||||
|
||||
def test_model_registration(self):
|
||||
self.assertIn('dummy_plugin', registry['models'])
|
||||
self.assertIn('dummymodel', registry['models']['dummy_plugin'])
|
||||
|
||||
def test_models(self):
|
||||
from netbox.tests.dummy_plugin.models import DummyModel
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ from django.http import HttpResponse
|
|||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django_tables2.export import TableExport
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
|
@ -320,7 +321,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||
if type(field.widget) is not HiddenInput
|
||||
}
|
||||
|
||||
def _save_object(self, model_form, request):
|
||||
def _save_object(self, import_form, model_form, request):
|
||||
|
||||
# Save the primary object
|
||||
obj = self.save_object(model_form, request)
|
||||
|
@ -345,11 +346,14 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||
related_obj = f.save()
|
||||
related_obj_pks.append(related_obj.pk)
|
||||
else:
|
||||
# Replicate errors on the related object form to the primary form for display
|
||||
# Replicate errors on the related object form to the import form for display and abort
|
||||
for subfield_name, errors in f.errors.items():
|
||||
for err in errors:
|
||||
err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
|
||||
model_form.add_error(None, err_msg)
|
||||
if subfield_name == '__all__':
|
||||
err_msg = f"{field_name}[{i}]: {err}"
|
||||
else:
|
||||
err_msg = f"{field_name}[{i}] {subfield_name}: {err}"
|
||||
import_form.add_error(None, err_msg)
|
||||
raise AbortTransaction()
|
||||
|
||||
# Enforce object-level permissions on related objects
|
||||
|
@ -390,7 +394,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||
try:
|
||||
instance = prefetched_objects[object_id]
|
||||
except KeyError:
|
||||
form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
|
||||
form.add_error('data', _("Row {i}: Object with ID {id} does not exist").format(i=i, id=object_id))
|
||||
raise ValidationError('')
|
||||
|
||||
# Take a snapshot for change logging
|
||||
|
@ -416,7 +420,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||
restrict_form_fields(model_form, request.user)
|
||||
|
||||
if model_form.is_valid():
|
||||
obj = self._save_object(model_form, request)
|
||||
obj = self._save_object(form, model_form, request)
|
||||
saved_objects.append(obj)
|
||||
else:
|
||||
# Replicate model form errors for display
|
||||
|
|
|
@ -11,6 +11,7 @@ from django.shortcuts import redirect, render
|
|||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.signals import clear_events
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
|
@ -101,7 +102,9 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
|||
request: The current request
|
||||
parent: The parent object
|
||||
"""
|
||||
raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
|
||||
raise NotImplementedError(_('{class_name} must implement get_children()').format(
|
||||
class_name=self.__class__.__name__
|
||||
))
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
"""
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -11,6 +11,7 @@ export function initStaticSelects(): void {
|
|||
)) {
|
||||
new TomSelect(select, {
|
||||
...config,
|
||||
maxOptions: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -26,6 +27,7 @@ export function initColorSelects(): void {
|
|||
for (const select of getElements<HTMLSelectElement>('select:not(.tomselected).color-select')) {
|
||||
new TomSelect(select, {
|
||||
...config,
|
||||
maxOptions: undefined,
|
||||
render: {
|
||||
option: renderColor,
|
||||
item: renderColor,
|
||||
|
|
|
@ -33,6 +33,10 @@
|
|||
<th scope="row">{% trans "Account Created" %}</th>
|
||||
<td>{{ request.user.date_joined|annotated_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Last Login" %}</th>
|
||||
<td>{{ request.user.last_login|annotated_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Superuser" %}</th>
|
||||
<td>{% checkmark request.user.is_superuser %}</td>
|
||||
|
|
|
@ -34,11 +34,11 @@
|
|||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Height (U" %})</th>
|
||||
<th scope="row">{% trans "Height (U)" %}</th>
|
||||
<td>{{ object.u_height|floatformat }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Exclude From Utilization" %})</th>
|
||||
<th scope="row">{% trans "Exclude From Utilization" %}</th>
|
||||
<td>{% checkmark object.exclude_from_utilization %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue