9856 merge feature
This commit is contained in:
commit
13bf2c1940
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ mkdocs-material
|
|||
mkdocstrings[python-legacy]
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||
netaddr
|
||||
|
||||
# Python bindings to the ammonia HTML sanitization library.
|
||||
|
|
|
|||
|
|
@ -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` )
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ When a device has one or more interfaces with IP addresses assigned, a primary I
|
|||
|
||||
The device's configured name. This field is optional; devices can be unnamed. However, if set, the name must be unique to the assigned site and tenant.
|
||||
|
||||
### Device Role
|
||||
### Role
|
||||
|
||||
The functional [role](./devicerole.md) assigned to this device.
|
||||
The functional [device role](./devicerole.md) assigned to this device.
|
||||
|
||||
### Device Type
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ from circuits.choices import CircuitStatusChoices
|
|||
from circuits.models import *
|
||||
from dcim.api.nested_serializers import NestedSiteSerializer
|
||||
from dcim.api.serializers import CabledObjectSerializer
|
||||
from ipam.models import ASN
|
||||
from ipam.api.nested_serializers import NestedASNSerializer
|
||||
from ipam.models import ASN
|
||||
from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
|
|
@ -40,6 +40,7 @@ class ProviderSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'circuit_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -56,6 +57,7 @@ class ProviderAccountSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -72,6 +74,7 @@ class ProviderNetworkSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -90,6 +93,7 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'circuit_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
|
||||
|
||||
|
||||
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
|
|
@ -122,6 +126,7 @@ class CircuitSerializer(NetBoxModelSerializer):
|
|||
'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'cid', 'description')
|
||||
|
||||
|
||||
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||
|
|
@ -137,3 +142,4 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
|
|||
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
|
||||
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
|
||||
'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class DataFileSerializer(NetBoxModelSerializer):
|
||||
|
|
@ -51,6 +52,7 @@ class DataFileSerializer(NetBoxModelSerializer):
|
|||
fields = [
|
||||
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'path')
|
||||
|
||||
|
||||
class JobSerializer(BaseModelSerializer):
|
||||
|
|
@ -69,3 +71,4 @@ class JobSerializer(BaseModelSerializer):
|
|||
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
|
||||
'started', 'completed', 'user', 'data', 'error', 'job_id',
|
||||
]
|
||||
brief_fields = ('url', 'created', 'completed', 'user', 'status')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ class ConfigView(generic.ObjectView):
|
|||
except ConfigRevision.DoesNotExist:
|
||||
# Fall back to using the active config data if no record is found
|
||||
return ConfigRevision(
|
||||
data=get_config()
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ class RegionSerializer(NestedGroupModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'site_count', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
|
||||
|
||||
|
||||
class SiteGroupSerializer(NestedGroupModelSerializer):
|
||||
|
|
@ -127,6 +128,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'site_count', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
|
||||
|
||||
|
||||
class SiteSerializer(NetBoxModelSerializer):
|
||||
|
|
@ -159,6 +161,7 @@ class SiteSerializer(NetBoxModelSerializer):
|
|||
'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
|
||||
'virtualmachine_count', 'vlan_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -180,6 +183,7 @@ class LocationSerializer(NestedGroupModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
|
||||
|
||||
|
||||
class RackRoleSerializer(NetBoxModelSerializer):
|
||||
|
|
@ -194,6 +198,7 @@ class RackRoleSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'rack_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
|
||||
|
||||
|
||||
class RackSerializer(NetBoxModelSerializer):
|
||||
|
|
@ -222,6 +227,7 @@ class RackSerializer(NetBoxModelSerializer):
|
|||
'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
|
||||
|
||||
|
||||
class RackUnitSerializer(serializers.Serializer):
|
||||
|
|
@ -256,6 +262,7 @@ class RackReservationSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
|
||||
'comments', 'tags', 'custom_fields',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
|
||||
|
||||
|
||||
class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
|
|
@ -315,6 +322,7 @@ class ManufacturerSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'devicetype_count', 'inventoryitem_count', 'platform_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
|
||||
|
||||
|
||||
class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||
|
|
@ -331,6 +339,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
|||
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
|
||||
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
|
||||
front_image = serializers.URLField(allow_null=True, required=False)
|
||||
rear_image = serializers.URLField(allow_null=True, required=False)
|
||||
|
||||
# Counter fields
|
||||
console_port_template_count = serializers.IntegerField(read_only=True)
|
||||
|
|
@ -358,6 +368,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
|||
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
|
||||
'inventory_item_template_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
|
||||
|
||||
|
||||
class ModuleTypeSerializer(NetBoxModelSerializer):
|
||||
|
|
@ -371,6 +382,7 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
|
||||
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -401,6 +413,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
|
@ -427,6 +440,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
|
@ -454,6 +468,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
|
||||
'allocated_draw', 'description', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
|
|
@ -491,6 +506,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
|
||||
'description', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
||||
|
|
@ -535,6 +551,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
|
||||
'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class RearPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
|
@ -557,6 +574,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
|
||||
'description', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class FrontPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
|
@ -580,6 +598,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
|
||||
'rear_port_position', 'description', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class ModuleBayTemplateSerializer(ValidatedModelSerializer):
|
||||
|
|
@ -592,6 +611,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
|
||||
|
|
@ -601,6 +621,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
|
|||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class InventoryItemTemplateSerializer(ValidatedModelSerializer):
|
||||
|
|
@ -627,6 +648,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
|
||||
'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_component(self, obj):
|
||||
|
|
@ -655,6 +677,7 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
|
||||
|
||||
|
||||
class PlatformSerializer(NetBoxModelSerializer):
|
||||
|
|
@ -672,13 +695,13 @@ class PlatformSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
|
||||
|
||||
|
||||
class DeviceSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
role = NestedDeviceRoleSerializer()
|
||||
device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.')
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
|
||||
platform = NestedPlatformSerializer(required=False, allow_null=True)
|
||||
site = NestedSiteSerializer()
|
||||
|
|
@ -720,14 +743,15 @@ class DeviceSerializer(NetBoxModelSerializer):
|
|||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
|
||||
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
|
||||
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
|
||||
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
|
||||
'device_bay_count', 'module_bay_count', 'inventory_item_count',
|
||||
'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
|
||||
'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
|
||||
'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
|
||||
'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
|
||||
'module_bay_count', 'inventory_item_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
@extend_schema_field(NestedDeviceSerializer)
|
||||
def get_parent_device(self, obj):
|
||||
|
|
@ -740,22 +764,19 @@ class DeviceSerializer(NetBoxModelSerializer):
|
|||
data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
|
||||
return data
|
||||
|
||||
def get_device_role(self, obj):
|
||||
return obj.role
|
||||
|
||||
|
||||
class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
config_context = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta(DeviceSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
|
||||
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
|
||||
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
|
||||
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
|
||||
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
|
||||
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
|
||||
'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
|
||||
'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
|
||||
'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
|
||||
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
|
||||
'device_bay_count', 'module_bay_count', 'inventory_item_count',
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
|
|
@ -782,6 +803,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
|||
'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'interface_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
|
||||
|
||||
|
||||
class ModuleSerializer(NetBoxModelSerializer):
|
||||
|
|
@ -797,6 +819,7 @@ class ModuleSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
|
||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -829,6 +852,7 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
|
|||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
|
|
@ -857,6 +881,7 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
|
|||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
|
|
@ -891,6 +916,7 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
|
|||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
|
|
@ -915,6 +941,7 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
|
|
@ -977,6 +1004,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
|
|
@ -1008,6 +1036,7 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
|||
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
|
|
@ -1038,6 +1067,7 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
|||
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
|
||||
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class ModuleBaySerializer(NetBoxModelSerializer):
|
||||
|
|
@ -1049,9 +1079,9 @@ class ModuleBaySerializer(NetBoxModelSerializer):
|
|||
model = ModuleBay
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
|
||||
'custom_fields',
|
||||
'created', 'last_updated',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
|
||||
|
||||
|
||||
class DeviceBaySerializer(NetBoxModelSerializer):
|
||||
|
|
@ -1065,6 +1095,7 @@ class DeviceBaySerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
|
||||
|
||||
|
||||
class InventoryItemSerializer(NetBoxModelSerializer):
|
||||
|
|
@ -1088,6 +1119,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
|
|||
'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_component(self, obj):
|
||||
|
|
@ -1114,6 +1146,7 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'inventoryitem_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -1134,6 +1167,7 @@ class CableSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'label', 'description')
|
||||
|
||||
|
||||
class TracedCableSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -1204,6 +1238,7 @@ class VirtualChassisSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'member_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -1228,6 +1263,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'powerfeed_count', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
|
||||
|
||||
|
||||
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
|
|
@ -1267,3 +1303,4 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
|
||||
'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')
|
||||
|
|
|
|||
|
|
@ -173,6 +173,12 @@ class RackViewSet(NetBoxModelViewSet):
|
|||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filtersets.RackFilterSet
|
||||
|
||||
@extend_schema(
|
||||
operation_id='dcim_racks_elevation_retrieve',
|
||||
filters=False,
|
||||
parameters=[serializers.RackElevationDetailFilterSerializer],
|
||||
responses={200: serializers.RackUnitSerializer(many=True)}
|
||||
)
|
||||
@action(detail=True)
|
||||
def elevation(self, request, pk=None):
|
||||
"""
|
||||
|
|
@ -372,12 +378,8 @@ class DeviceViewSet(
|
|||
|
||||
Else, return the DeviceWithConfigContextSerializer
|
||||
"""
|
||||
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief', False):
|
||||
return serializers.NestedDeviceSerializer
|
||||
|
||||
elif 'config_context' in request.query_params.get('exclude', []):
|
||||
if self.brief or 'config_context' in request.query_params.get('exclude', []):
|
||||
return serializers.DeviceSerializer
|
||||
|
||||
return serializers.DeviceWithConfigContextSerializer
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -815,20 +815,6 @@ class Device(
|
|||
def get_absolute_url(self):
|
||||
return reverse('dcim:device', args=[self.pk])
|
||||
|
||||
@property
|
||||
def device_role(self):
|
||||
"""
|
||||
For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
|
||||
"""
|
||||
return self.role
|
||||
|
||||
@device_role.setter
|
||||
def device_role(self, value):
|
||||
"""
|
||||
For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
|
||||
"""
|
||||
self.role = value
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
|
@ -875,7 +861,7 @@ class Device(
|
|||
if self.position and self.device_type.u_height == 0:
|
||||
raise ValidationError({
|
||||
'position': _(
|
||||
"A U0 device type ({device_type}) cannot be assigned to a rack position."
|
||||
"A 0U device type ({device_type}) cannot be assigned to a rack position."
|
||||
).format(device_type=self.device_type)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2156,7 +2156,7 @@ class CablePathTestCase(TestCase):
|
|||
device = Device.objects.create(
|
||||
site=self.site,
|
||||
device_type=self.device.device_type,
|
||||
device_role=self.device.device_role,
|
||||
role=self.device.role,
|
||||
name='Test mid-span Device'
|
||||
)
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -533,30 +533,6 @@ class DeviceTestCase(TestCase):
|
|||
device2.full_clean()
|
||||
device2.save()
|
||||
|
||||
def test_old_device_role_field(self):
|
||||
"""
|
||||
Ensure that the old device role field sets the value in the new role field.
|
||||
"""
|
||||
|
||||
# Test getter method
|
||||
device = Device(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
role=DeviceRole.objects.first(),
|
||||
name='Test Device 1',
|
||||
device_role=DeviceRole.objects.first()
|
||||
)
|
||||
device.full_clean()
|
||||
device.save()
|
||||
|
||||
self.assertEqual(device.role, device.device_role)
|
||||
|
||||
# Test setter method
|
||||
device.device_role = DeviceRole.objects.last()
|
||||
device.full_clean()
|
||||
device.save()
|
||||
self.assertEqual(device.role, device.device_role)
|
||||
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
|
||||
|
|
|
|||
|
|
@ -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,5 +1,6 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
|
@ -43,9 +44,6 @@ __all__ = (
|
|||
'ImageAttachmentSerializer',
|
||||
'JournalEntrySerializer',
|
||||
'ObjectChangeSerializer',
|
||||
'ReportDetailSerializer',
|
||||
'ReportSerializer',
|
||||
'ReportInputSerializer',
|
||||
'SavedFilterSerializer',
|
||||
'ScriptDetailSerializer',
|
||||
'ScriptInputSerializer',
|
||||
|
|
@ -78,15 +76,16 @@ class EventRuleSerializer(NetBoxModelSerializer):
|
|||
'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
|
||||
'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
@extend_schema_field(OpenApiTypes.OBJECT)
|
||||
def get_action_object(self, instance):
|
||||
context = {'request': self.context['request']}
|
||||
# We need to manually instantiate the serializer for scripts
|
||||
if instance.action_type == EventRuleActionChoices.SCRIPT:
|
||||
script_name = instance.action_parameters['script_name']
|
||||
script = instance.action_object.scripts[script_name]()
|
||||
return NestedScriptSerializer(script, context=context).data
|
||||
script = instance.action_object
|
||||
instance = script.python_class() if script.python_class else None
|
||||
return NestedScriptSerializer(instance, context=context).data
|
||||
else:
|
||||
serializer = get_serializer_for_model(
|
||||
model=instance.action_object_type.model_class(),
|
||||
|
|
@ -109,6 +108,7 @@ class WebhookSerializer(NetBoxModelSerializer):
|
|||
'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields',
|
||||
'tags', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -144,10 +144,11 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
def validate_type(self, value):
|
||||
if self.instance and self.instance.type != value:
|
||||
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
|
||||
raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
|
||||
|
||||
return value
|
||||
|
||||
|
|
@ -186,6 +187,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
|
||||
'choices_count', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -205,6 +207,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
|
||||
'button_class', 'new_window', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -231,6 +234,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
|
|||
'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -250,6 +254,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
|
||||
'shared', 'parameters', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -269,6 +274,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
|
|||
fields = [
|
||||
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'object_id', 'object_type')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_object(self, instance):
|
||||
|
|
@ -297,6 +303,7 @@ class TagSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -316,6 +323,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
|
|||
'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
|
||||
'image_width', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'image')
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
|
|
@ -365,6 +373,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
|
|||
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
|
||||
'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'created')
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
|
|
@ -488,6 +497,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
|||
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -509,81 +519,58 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
|
|||
'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
|
||||
'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
|
||||
class ReportSerializer(serializers.Serializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras-api:report-detail',
|
||||
lookup_field='full_name',
|
||||
lookup_url_kwarg='pk'
|
||||
)
|
||||
id = serializers.CharField(read_only=True, source="full_name")
|
||||
module = serializers.CharField(max_length=255)
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(max_length=255, required=False)
|
||||
test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
|
||||
result = NestedJobSerializer()
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
||||
|
||||
|
||||
class ReportDetailSerializer(ReportSerializer):
|
||||
result = JobSerializer()
|
||||
|
||||
|
||||
class ReportInputSerializer(serializers.Serializer):
|
||||
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
interval = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['report'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this report.")
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
if value and not self.context['report'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this report.")
|
||||
return value
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptSerializer(serializers.Serializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras-api:script-detail',
|
||||
lookup_field='full_name',
|
||||
lookup_url_kwarg='pk'
|
||||
)
|
||||
id = serializers.CharField(read_only=True, source="full_name")
|
||||
module = serializers.CharField(max_length=255)
|
||||
name = serializers.CharField(read_only=True)
|
||||
description = serializers.CharField(read_only=True)
|
||||
class ScriptSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
|
||||
description = serializers.SerializerMethodField(read_only=True)
|
||||
vars = serializers.SerializerMethodField(read_only=True)
|
||||
result = NestedJobSerializer()
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
result = NestedJobSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Script
|
||||
fields = [
|
||||
'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_vars(self, instance):
|
||||
return {
|
||||
k: v.__class__.__name__ for k, v in instance._get_vars().items()
|
||||
}
|
||||
def get_vars(self, obj):
|
||||
if obj.python_class:
|
||||
return {
|
||||
k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
|
||||
}
|
||||
else:
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_description(self, obj):
|
||||
if obj.python_class:
|
||||
return obj.python_class().description
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class ScriptDetailSerializer(ScriptSerializer):
|
||||
result = JobSerializer()
|
||||
result = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@extend_schema_field(JobSerializer())
|
||||
def get_result(self, obj):
|
||||
job = obj.jobs.all().order_by('-created').first()
|
||||
context = {
|
||||
'request': self.context['request']
|
||||
}
|
||||
data = JobSerializer(job, context=context).data
|
||||
return data
|
||||
|
||||
|
||||
class ScriptInputSerializer(serializers.Serializer):
|
||||
|
|
@ -594,12 +581,12 @@ class ScriptInputSerializer(serializers.Serializer):
|
|||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this script.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this script.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
|
|
@ -9,14 +8,13 @@ from rest_framework.generics import RetrieveUpdateDestroyAPIView
|
|||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
from rq import Worker
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from extras import filtersets
|
||||
from extras.models import *
|
||||
from extras.scripts import get_module_and_script, run_script
|
||||
from extras.scripts import run_script
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.features import SyncedDataMixin
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
|
|
@ -209,66 +207,30 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
|
|||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptViewSet(ViewSet):
|
||||
class ScriptViewSet(ModelViewSet):
|
||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||
queryset = Script.objects.prefetch_related('jobs')
|
||||
serializer_class = serializers.ScriptSerializer
|
||||
filterset_class = filtersets.ScriptFilterSet
|
||||
|
||||
_ignore_model_permissions = True
|
||||
schema = None
|
||||
lookup_value_regex = '[^/]+' # Allow dots
|
||||
|
||||
def _get_script(self, pk):
|
||||
try:
|
||||
module_name, script_name = pk.split('.', maxsplit=1)
|
||||
except ValueError:
|
||||
raise Http404
|
||||
|
||||
module, script = get_module_and_script(module_name, script_name)
|
||||
if script is None:
|
||||
raise Http404
|
||||
|
||||
return module, script
|
||||
|
||||
def list(self, request):
|
||||
results = {
|
||||
job.name: job
|
||||
for job in Job.objects.filter(
|
||||
object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).order_by('name', '-created').distinct('name').defer('data')
|
||||
}
|
||||
|
||||
script_list = []
|
||||
for script_module in ScriptModule.objects.restrict(request.user):
|
||||
script_list.extend(script_module.scripts.values())
|
||||
|
||||
# Attach Job objects to each script (if any)
|
||||
for script in script_list:
|
||||
script.result = results.get(script.class_name, None)
|
||||
|
||||
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
|
||||
|
||||
return Response({'count': len(script_list), 'results': serializer.data})
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
module, script = self._get_script(pk)
|
||||
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
script.result = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
name=script.class_name,
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
script = get_object_or_404(self.queryset, pk=pk)
|
||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request, pk):
|
||||
"""
|
||||
Run a Script identified as "<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}
|
||||
|
|
@ -281,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)
|
||||
|
|
|
|||
|
|
@ -920,7 +920,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'),
|
||||
|
|
@ -961,7 +961,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'),
|
||||
|
|
@ -997,9 +997,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'))
|
||||
|
||||
|
|
@ -1030,7 +1030,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,
|
||||
|
|
@ -1038,123 +1038,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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -54,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):
|
||||
|
|
@ -104,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')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -120,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')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -138,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):
|
||||
|
|
@ -153,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')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -169,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):
|
||||
|
|
@ -185,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):
|
||||
|
|
@ -212,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):
|
||||
|
|
@ -237,6 +246,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
|
|||
'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))
|
||||
|
|
@ -267,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):
|
||||
|
|
@ -327,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):
|
||||
|
|
@ -397,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')
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -427,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):
|
||||
|
|
@ -469,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):
|
||||
|
|
@ -489,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
|
||||
|
|
@ -354,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,4 +1,5 @@
|
|||
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 netaddr import IPNetwork
|
||||
|
|
@ -59,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'):
|
||||
|
|
@ -83,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):
|
||||
|
|
@ -96,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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,6 +44,13 @@ 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()
|
||||
|
|
@ -66,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,
|
||||
|
|
@ -85,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,35 +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
|
||||
|
||||
|
||||
class CustomFieldsMixin:
|
||||
"""
|
||||
For models which support custom fields, populate the `custom_fields` context.
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ from collections import defaultdict
|
|||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
|
||||
from django.contrib.auth.models import Group, AnonymousUser
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from users.constants import CONSTRAINT_TOKEN_USER
|
||||
from users.models import ObjectPermission
|
||||
from users.models import Group, ObjectPermission
|
||||
from utilities.permissions import (
|
||||
permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
|
||||
)
|
||||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -392,19 +392,19 @@ ADMIN_MENU = Menu(
|
|||
),
|
||||
# Proxy model for auth.Group
|
||||
MenuItem(
|
||||
link=f'users:netboxgroup_list',
|
||||
link=f'users:group_list',
|
||||
link_text=_('Groups'),
|
||||
permissions=[f'auth.view_group'],
|
||||
staff_only=True,
|
||||
buttons=(
|
||||
MenuItemButton(
|
||||
link=f'users:netboxgroup_add',
|
||||
link=f'users:group_add',
|
||||
title='Add',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
permissions=[f'auth.add_group']
|
||||
),
|
||||
MenuItemButton(
|
||||
link=f'users:netboxgroup_import',
|
||||
link=f'users:group_import',
|
||||
title='Import',
|
||||
icon_class='mdi mdi-upload',
|
||||
permissions=[f'auth.add_group']
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import datetime
|
|||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import Client
|
||||
from django.test.utils import override_settings
|
||||
|
|
@ -12,7 +11,7 @@ from rest_framework.test import APIClient
|
|||
|
||||
from dcim.models import Site
|
||||
from ipam.models import Prefix
|
||||
from users.models import ObjectPermission, Token
|
||||
from users.models import Group, ObjectPermission, Token
|
||||
from utilities.testing import TestCase
|
||||
from utilities.testing.api import APITestCase
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -319,7 +320,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)
|
||||
|
|
@ -344,11 +345,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
|
||||
|
|
@ -389,7 +393,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
|
||||
|
|
@ -415,7 +419,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
|
||||
|
|
@ -100,7 +101,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
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 109 B |
|
|
@ -18,11 +18,12 @@ function handleSelection(link: HTMLAnchorElement): void {
|
|||
const value = link.getAttribute('data-value');
|
||||
|
||||
//@ts-ignore
|
||||
target.slim.setData([
|
||||
{text: label, value: value}
|
||||
]);
|
||||
const change = new Event('change');
|
||||
target.dispatchEvent(change);
|
||||
target.tomselect.addOption({
|
||||
id: value,
|
||||
display: label,
|
||||
});
|
||||
//@ts-ignore
|
||||
target.tomselect.addItem(value);
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function renderItem(data: TomOption, escape: typeof escape_html) {
|
|||
|
||||
// Initialize <select> elements which are populated via a REST API call
|
||||
export function initDynamicSelects(): void {
|
||||
for (const select of getElements<HTMLSelectElement>('select.api-select')) {
|
||||
for (const select of getElements<HTMLSelectElement>('select.api-select:not(.tomselected)')) {
|
||||
new DynamicTomSelect(select, {
|
||||
...config,
|
||||
valueField: VALUE_FIELD,
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import { getElements } from '../util';
|
|||
// Initialize <select> elements with statically-defined options
|
||||
export function initStaticSelects(): void {
|
||||
for (const select of getElements<HTMLSelectElement>(
|
||||
'select:not(.api-select):not(.color-select)',
|
||||
'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)',
|
||||
)) {
|
||||
new TomSelect(select, {
|
||||
...config,
|
||||
maxOptions: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -23,9 +24,10 @@ export function initColorSelects(): void {
|
|||
)}"></span> ${escape(item.text)}</div>`;
|
||||
}
|
||||
|
||||
for (const select of getElements<HTMLSelectElement>('select.color-select')) {
|
||||
for (const select of getElements<HTMLSelectElement>('select.color-select:not(.tomselected)')) {
|
||||
new TomSelect(select, {
|
||||
...config,
|
||||
maxOptions: undefined,
|
||||
render: {
|
||||
option: renderColor,
|
||||
item: renderColor,
|
||||
|
|
|
|||
|
|
@ -244,29 +244,6 @@ export function getSelectedOptions<E extends HTMLElement>(
|
|||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data that can only be accessed via Django context, and is thus already rendered in the HTML
|
||||
* template.
|
||||
*
|
||||
* @see Templates requiring Django context data have a `{% block data %}` block.
|
||||
*
|
||||
* @param key Property name, which must exist on the HTML element. If not already prefixed with
|
||||
* `data-`, `data-` will be prepended to the property.
|
||||
* @returns Value if it exists, `null` if not.
|
||||
*/
|
||||
export function getNetboxData(key: string): string | null {
|
||||
if (!key.startsWith('data-')) {
|
||||
key = `data-${key}`;
|
||||
}
|
||||
for (const element of getElements('body > div#netbox-data > *')) {
|
||||
const value = element.getAttribute(key);
|
||||
if (isTruthy(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility of an element.
|
||||
*/
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue