9856 merge feature

This commit is contained in:
Arthur 2024-03-04 14:18:58 -08:00
commit 13bf2c1940
186 changed files with 10628 additions and 5519 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,9 +18,9 @@ When a device has one or more interfaces with IP addresses assigned, a primary I
The device's configured name. This field is optional; devices can be unnamed. However, if set, the name must be unique to the assigned site and tenant. The device's configured name. This field is optional; devices can be unnamed. However, if set, the name must be unique to the assigned site and tenant.
### Device Role ### Role
The functional [role](./devicerole.md) assigned to this device. The functional [device role](./devicerole.md) assigned to this device.
### Device Type ### Device Type

View File

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

View File

@ -13,6 +13,10 @@
The NetBox user interface has been completely refreshed and updated. The NetBox user interface has been completely refreshed and updated.
#### Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087))
The REST API now supports specifying which fields to include in the response data.
### Enhancements ### Enhancements
* [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3 * [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
@ -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 * [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12
* [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI * [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI
* [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI * [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI
* [#14438](https://github.com/netbox-community/netbox/issues/14438) - Track individual custom scripts as database objects
* [#15131](https://github.com/netbox-community/netbox/issues/15131) - Automatically annotate related object counts on REST API querysets
* [#15238](https://github.com/netbox-community/netbox/issues/15238) - Include the `description` field in "brief" REST API serializations
### Other Changes ### Other Changes
@ -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` * [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin`
* [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`) * [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`)
* [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class * [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class
* [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features
* [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices * [#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 * [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class

View File

@ -4,8 +4,8 @@ from circuits.choices import CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.api.nested_serializers import NestedSiteSerializer from dcim.api.nested_serializers import NestedSiteSerializer
from dcim.api.serializers import CabledObjectSerializer from dcim.api.serializers import CabledObjectSerializer
from ipam.models import ASN
from ipam.api.nested_serializers import NestedASNSerializer from ipam.api.nested_serializers import NestedASNSerializer
from ipam.models import ASN
from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
@ -40,6 +40,7 @@ class ProviderSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags', 'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count', 'custom_fields', 'created', 'last_updated', 'circuit_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
# #
@ -56,6 +57,7 @@ class ProviderAccountSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
# #
@ -72,6 +74,7 @@ class ProviderNetworkSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags', 'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -90,6 +93,7 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'circuit_count', 'last_updated', 'circuit_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
class CircuitCircuitTerminationSerializer(WritableNestedSerializer): class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
@ -122,6 +126,7 @@ class CircuitSerializer(NetBoxModelSerializer):
'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'cid', 'description')
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer): class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
@ -137,3 +142,4 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')

View File

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

View File

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

View File

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

View File

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

View File

@ -36,6 +36,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments', 'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count', 'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class DataFileSerializer(NetBoxModelSerializer): class DataFileSerializer(NetBoxModelSerializer):
@ -51,6 +52,7 @@ class DataFileSerializer(NetBoxModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash', 'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
] ]
brief_fields = ('id', 'url', 'display', 'path')
class JobSerializer(BaseModelSerializer): class JobSerializer(BaseModelSerializer):
@ -69,3 +71,4 @@ class JobSerializer(BaseModelSerializer):
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval', 'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
'started', 'completed', 'user', 'data', 'error', 'job_id', 'started', 'completed', 'user', 'data', 'error', 'job_id',
] ]
brief_fields = ('url', 'created', 'completed', 'user', 'status')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -114,6 +114,7 @@ class RegionSerializer(NestedGroupModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'site_count', '_depth', 'last_updated', 'site_count', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
class SiteGroupSerializer(NestedGroupModelSerializer): class SiteGroupSerializer(NestedGroupModelSerializer):
@ -127,6 +128,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'site_count', '_depth', 'last_updated', 'site_count', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
class SiteSerializer(NetBoxModelSerializer): class SiteSerializer(NetBoxModelSerializer):
@ -159,6 +161,7 @@ class SiteSerializer(NetBoxModelSerializer):
'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
'virtualmachine_count', 'vlan_count', 'virtualmachine_count', 'vlan_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
# #
@ -180,6 +183,7 @@ class LocationSerializer(NestedGroupModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags', 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
class RackRoleSerializer(NetBoxModelSerializer): class RackRoleSerializer(NetBoxModelSerializer):
@ -194,6 +198,7 @@ class RackRoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'rack_count', 'last_updated', 'rack_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
class RackSerializer(NetBoxModelSerializer): class RackSerializer(NetBoxModelSerializer):
@ -222,6 +227,7 @@ class RackSerializer(NetBoxModelSerializer):
'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
class RackUnitSerializer(serializers.Serializer): class RackUnitSerializer(serializers.Serializer):
@ -256,6 +262,7 @@ class RackReservationSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
'comments', 'tags', 'custom_fields', 'comments', 'tags', 'custom_fields',
] ]
brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
class RackElevationDetailFilterSerializer(serializers.Serializer): class RackElevationDetailFilterSerializer(serializers.Serializer):
@ -315,6 +322,7 @@ class ManufacturerSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'devicetype_count', 'inventoryitem_count', 'platform_count', 'devicetype_count', 'inventoryitem_count', 'platform_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
class DeviceTypeSerializer(NetBoxModelSerializer): class DeviceTypeSerializer(NetBoxModelSerializer):
@ -331,6 +339,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
front_image = serializers.URLField(allow_null=True, required=False)
rear_image = serializers.URLField(allow_null=True, required=False)
# Counter fields # Counter fields
console_port_template_count = serializers.IntegerField(read_only=True) console_port_template_count = serializers.IntegerField(read_only=True)
@ -358,6 +368,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count', 'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count', 'inventory_item_template_count',
] ]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
class ModuleTypeSerializer(NetBoxModelSerializer): class ModuleTypeSerializer(NetBoxModelSerializer):
@ -371,6 +382,7 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
# #
@ -401,6 +413,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@ -427,6 +440,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class PowerPortTemplateSerializer(ValidatedModelSerializer): class PowerPortTemplateSerializer(ValidatedModelSerializer):
@ -454,6 +468,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
'allocated_draw', 'description', 'created', 'last_updated', 'allocated_draw', 'description', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class PowerOutletTemplateSerializer(ValidatedModelSerializer): class PowerOutletTemplateSerializer(ValidatedModelSerializer):
@ -491,6 +506,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
'description', 'created', 'last_updated', 'description', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class InterfaceTemplateSerializer(ValidatedModelSerializer): class InterfaceTemplateSerializer(ValidatedModelSerializer):
@ -535,6 +551,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated', 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class RearPortTemplateSerializer(ValidatedModelSerializer): class RearPortTemplateSerializer(ValidatedModelSerializer):
@ -557,6 +574,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
'description', 'created', 'last_updated', 'description', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class FrontPortTemplateSerializer(ValidatedModelSerializer): class FrontPortTemplateSerializer(ValidatedModelSerializer):
@ -580,6 +598,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'created', 'last_updated', 'rear_port_position', 'description', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ModuleBayTemplateSerializer(ValidatedModelSerializer): class ModuleBayTemplateSerializer(ValidatedModelSerializer):
@ -592,6 +611,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created', 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class DeviceBayTemplateSerializer(ValidatedModelSerializer): class DeviceBayTemplateSerializer(ValidatedModelSerializer):
@ -601,6 +621,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = DeviceBayTemplate model = DeviceBayTemplate
fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
brief_fields = ('id', 'url', 'display', 'name', 'description')
class InventoryItemTemplateSerializer(ValidatedModelSerializer): class InventoryItemTemplateSerializer(ValidatedModelSerializer):
@ -627,6 +648,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth', 'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth')
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_component(self, obj): def get_component(self, obj):
@ -655,6 +677,7 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
class PlatformSerializer(NetBoxModelSerializer): class PlatformSerializer(NetBoxModelSerializer):
@ -672,13 +695,13 @@ class PlatformSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
class DeviceSerializer(NetBoxModelSerializer): class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
role = NestedDeviceRoleSerializer() role = NestedDeviceRoleSerializer()
device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.')
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer() site = NestedSiteSerializer()
@ -720,14 +743,15 @@ class DeviceSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial', 'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count', 'module_bay_count', 'inventory_item_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
@extend_schema_field(NestedDeviceSerializer) @extend_schema_field(NestedDeviceSerializer)
def get_parent_device(self, obj): def get_parent_device(self, obj):
@ -740,22 +764,19 @@ class DeviceSerializer(NetBoxModelSerializer):
data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
return data return data
def get_device_role(self, obj):
return obj.role
class DeviceWithConfigContextSerializer(DeviceSerializer): class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True) config_context = serializers.SerializerMethodField(read_only=True)
class Meta(DeviceSerializer.Meta): class Meta(DeviceSerializer.Meta):
fields = [ fields = [
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial', 'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags',
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count', 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count', 'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
@ -782,6 +803,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'interface_count', 'interface_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
class ModuleSerializer(NetBoxModelSerializer): class ModuleSerializer(NetBoxModelSerializer):
@ -797,6 +819,7 @@ class ModuleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
# #
@ -829,6 +852,7 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@ -857,6 +881,7 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@ -891,6 +916,7 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@ -915,6 +941,7 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@ -977,6 +1004,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
def validate(self, data): def validate(self, data):
@ -1008,6 +1036,7 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class FrontPortRearPortSerializer(WritableNestedSerializer): class FrontPortRearPortSerializer(WritableNestedSerializer):
@ -1038,6 +1067,7 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ModuleBaySerializer(NetBoxModelSerializer): class ModuleBaySerializer(NetBoxModelSerializer):
@ -1049,9 +1079,9 @@ class ModuleBaySerializer(NetBoxModelSerializer):
model = ModuleBay model = ModuleBay
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
'custom_fields', 'custom_fields', 'created', 'last_updated',
'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
class DeviceBaySerializer(NetBoxModelSerializer): class DeviceBaySerializer(NetBoxModelSerializer):
@ -1065,6 +1095,7 @@ class DeviceBaySerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags', 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
class InventoryItemSerializer(NetBoxModelSerializer): class InventoryItemSerializer(NetBoxModelSerializer):
@ -1088,6 +1119,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags', 'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
'custom_fields', 'created', 'last_updated', '_depth', 'custom_fields', 'created', 'last_updated', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_component(self, obj): def get_component(self, obj):
@ -1114,6 +1146,7 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'inventoryitem_count', 'last_updated', 'inventoryitem_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')
# #
@ -1134,6 +1167,7 @@ class CableSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color', 'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'label', 'description')
class TracedCableSerializer(serializers.ModelSerializer): class TracedCableSerializer(serializers.ModelSerializer):
@ -1204,6 +1238,7 @@ class VirtualChassisSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'member_count', 'created', 'last_updated', 'member_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')
# #
@ -1228,6 +1263,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
'powerfeed_count', 'created', 'last_updated', 'powerfeed_count', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@ -1267,3 +1303,4 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')

View File

@ -173,6 +173,12 @@ class RackViewSet(NetBoxModelViewSet):
serializer_class = serializers.RackSerializer serializer_class = serializers.RackSerializer
filterset_class = filtersets.RackFilterSet filterset_class = filtersets.RackFilterSet
@extend_schema(
operation_id='dcim_racks_elevation_retrieve',
filters=False,
parameters=[serializers.RackElevationDetailFilterSerializer],
responses={200: serializers.RackUnitSerializer(many=True)}
)
@action(detail=True) @action(detail=True)
def elevation(self, request, pk=None): def elevation(self, request, pk=None):
""" """
@ -372,12 +378,8 @@ class DeviceViewSet(
Else, return the DeviceWithConfigContextSerializer Else, return the DeviceWithConfigContextSerializer
""" """
request = self.get_serializer_context()['request'] request = self.get_serializer_context()['request']
if request.query_params.get('brief', False): if self.brief or 'config_context' in request.query_params.get('exclude', []):
return serializers.NestedDeviceSerializer
elif 'config_context' in request.query_params.get('exclude', []):
return serializers.DeviceSerializer return serializers.DeviceSerializer
return serializers.DeviceWithConfigContextSerializer return serializers.DeviceWithConfigContextSerializer

View File

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

View File

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

View File

@ -2,6 +2,8 @@ import django_filters
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from circuits.models import CircuitTermination from circuits.models import CircuitTermination
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
@ -818,6 +820,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
to_field_name='slug', to_field_name='slug',
label=_('Manufacturer (slug)'), label=_('Manufacturer (slug)'),
) )
available_for_device_type = django_filters.ModelChoiceFilter(
queryset=DeviceType.objects.all(),
method='get_for_device_type'
)
config_template_id = django_filters.ModelMultipleChoiceFilter( config_template_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
label=_('Config template (ID)'), label=_('Config template (ID)'),
@ -827,6 +833,14 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
model = Platform model = Platform
fields = ['id', 'name', 'slug', 'description'] fields = ['id', 'name', 'slug', 'description']
@extend_schema_field(OpenApiTypes.STR)
def get_for_device_type(self, queryset, name, value):
"""
Return all Platforms available for a specific manufacturer based on device type and Platforms not assigned any
manufacturer
"""
return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
class DeviceFilterSet( class DeviceFilterSet(
NetBoxModelFilterSet, NetBoxModelFilterSet,

View File

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

View File

@ -291,7 +291,11 @@ class DeviceTypeForm(NetBoxModelForm):
default_platform = DynamicModelChoiceField( default_platform = DynamicModelChoiceField(
label=_('Default platform'), label=_('Default platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False required=False,
selector=True,
query_params={
'manufacturer_id': ['$manufacturer', 'null'],
}
) )
slug = SlugField( slug = SlugField(
label=_('Slug'), label=_('Slug'),
@ -447,7 +451,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=_('Platform'), label=_('Platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False, required=False,
selector=True selector=True,
query_params={
'available_for_device_type': '$device_type',
}
) )
cluster = DynamicModelChoiceField( cluster = DynamicModelChoiceField(
label=_('Cluster'), label=_('Cluster'),

View File

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

View File

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

View File

@ -815,20 +815,6 @@ class Device(
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk]) return reverse('dcim:device', args=[self.pk])
@property
def device_role(self):
"""
For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
"""
return self.role
@device_role.setter
def device_role(self, value):
"""
For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
"""
self.role = value
def clean(self): def clean(self):
super().clean() super().clean()
@ -875,7 +861,7 @@ class Device(
if self.position and self.device_type.u_height == 0: if self.position and self.device_type.u_height == 0:
raise ValidationError({ raise ValidationError({
'position': _( 'position': _(
"A U0 device type ({device_type}) cannot be assigned to a rack position." "A 0U device type ({device_type}) cannot be assigned to a rack position."
).format(device_type=self.device_type) ).format(device_type=self.device_type)
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -533,30 +533,6 @@ class DeviceTestCase(TestCase):
device2.full_clean() device2.full_clean()
device2.save() device2.save()
def test_old_device_role_field(self):
"""
Ensure that the old device role field sets the value in the new role field.
"""
# Test getter method
device = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name='Test Device 1',
device_role=DeviceRole.objects.first()
)
device.full_clean()
device.save()
self.assertEqual(device.role, device.device_role)
# Test setter method
device.device_role = DeviceRole.objects.last()
device.full_clean()
device.save()
self.assertEqual(device.role, device.device_role)
class CableTestCase(TestCase): class CableTestCase(TestCase):

View File

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

View File

@ -1,5 +1,6 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
@ -43,9 +44,6 @@ __all__ = (
'ImageAttachmentSerializer', 'ImageAttachmentSerializer',
'JournalEntrySerializer', 'JournalEntrySerializer',
'ObjectChangeSerializer', 'ObjectChangeSerializer',
'ReportDetailSerializer',
'ReportSerializer',
'ReportInputSerializer',
'SavedFilterSerializer', 'SavedFilterSerializer',
'ScriptDetailSerializer', 'ScriptDetailSerializer',
'ScriptInputSerializer', 'ScriptInputSerializer',
@ -78,15 +76,16 @@ class EventRuleSerializer(NetBoxModelSerializer):
'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', 'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated', 'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
@extend_schema_field(OpenApiTypes.OBJECT) @extend_schema_field(OpenApiTypes.OBJECT)
def get_action_object(self, instance): def get_action_object(self, instance):
context = {'request': self.context['request']} context = {'request': self.context['request']}
# We need to manually instantiate the serializer for scripts # We need to manually instantiate the serializer for scripts
if instance.action_type == EventRuleActionChoices.SCRIPT: if instance.action_type == EventRuleActionChoices.SCRIPT:
script_name = instance.action_parameters['script_name'] script = instance.action_object
script = instance.action_object.scripts[script_name]() instance = script.python_class() if script.python_class else None
return NestedScriptSerializer(script, context=context).data return NestedScriptSerializer(instance, context=context).data
else: else:
serializer = get_serializer_for_model( serializer = get_serializer_for_model(
model=instance.action_object_type.model_class(), model=instance.action_object_type.model_class(),
@ -109,6 +108,7 @@ class WebhookSerializer(NetBoxModelSerializer):
'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields', 'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields',
'tags', 'created', 'last_updated', 'tags', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -144,10 +144,11 @@ class CustomFieldSerializer(ValidatedModelSerializer):
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
def validate_type(self, value): def validate_type(self, value):
if self.instance and self.instance.type != value: if self.instance and self.instance.type != value:
raise serializers.ValidationError('Changing the type of custom fields is not supported.') raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
return value return value
@ -186,6 +187,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
'choices_count', 'created', 'last_updated', 'choices_count', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')
# #
@ -205,6 +207,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', 'created', 'last_updated', 'button_class', 'new_window', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name')
# #
@ -231,6 +234,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -250,6 +254,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled', 'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
'shared', 'parameters', 'created', 'last_updated', 'shared', 'parameters', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
# #
@ -269,6 +274,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
] ]
brief_fields = ('id', 'url', 'display', 'object_id', 'object_type')
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_object(self, instance): def get_object(self, instance):
@ -297,6 +303,7 @@ class TagSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created', 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')
# #
@ -316,6 +323,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
'image_width', 'created', 'last_updated', 'image_width', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'image')
def validate(self, data): def validate(self, data):
@ -365,6 +373,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created', 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated', 'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'created')
def validate(self, data): def validate(self, data):
@ -488,6 +497,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -509,81 +519,58 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source', 'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated', 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
#
# Reports
#
class ReportSerializer(serializers.Serializer):
url = serializers.HyperlinkedIdentityField(
view_name='extras-api:report-detail',
lookup_field='full_name',
lookup_url_kwarg='pk'
)
id = serializers.CharField(read_only=True, source="full_name")
module = serializers.CharField(max_length=255)
name = serializers.CharField(max_length=255)
description = serializers.CharField(max_length=255, required=False)
test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
result = NestedJobSerializer()
display = serializers.SerializerMethodField(read_only=True)
@extend_schema_field(serializers.CharField())
def get_display(self, obj):
return f'{obj.name} ({obj.module})'
class ReportDetailSerializer(ReportSerializer):
result = JobSerializer()
class ReportInputSerializer(serializers.Serializer):
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)
def validate_schedule_at(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this report.")
return value
def validate_interval(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this report.")
return value
# #
# Scripts # Scripts
# #
class ScriptSerializer(serializers.Serializer): class ScriptSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField( url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
view_name='extras-api:script-detail', description = serializers.SerializerMethodField(read_only=True)
lookup_field='full_name',
lookup_url_kwarg='pk'
)
id = serializers.CharField(read_only=True, source="full_name")
module = serializers.CharField(max_length=255)
name = serializers.CharField(read_only=True)
description = serializers.CharField(read_only=True)
vars = serializers.SerializerMethodField(read_only=True) vars = serializers.SerializerMethodField(read_only=True)
result = NestedJobSerializer() result = NestedJobSerializer(read_only=True)
display = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Script
fields = [
'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_vars(self, instance): def get_vars(self, obj):
return { if obj.python_class:
k: v.__class__.__name__ for k, v in instance._get_vars().items() return {
} k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
}
else:
return {}
@extend_schema_field(serializers.CharField()) @extend_schema_field(serializers.CharField())
def get_display(self, obj): def get_display(self, obj):
return f'{obj.name} ({obj.module})' return f'{obj.name} ({obj.module})'
@extend_schema_field(serializers.CharField())
def get_description(self, obj):
if obj.python_class:
return obj.python_class().description
else:
return None
class ScriptDetailSerializer(ScriptSerializer): class ScriptDetailSerializer(ScriptSerializer):
result = JobSerializer() result = serializers.SerializerMethodField(read_only=True)
@extend_schema_field(JobSerializer())
def get_result(self, obj):
job = obj.jobs.all().order_by('-created').first()
context = {
'request': self.context['request']
}
data = JobSerializer(job, context=context).data
return data
class ScriptInputSerializer(serializers.Serializer): class ScriptInputSerializer(serializers.Serializer):
@ -594,12 +581,12 @@ class ScriptInputSerializer(serializers.Serializer):
def validate_schedule_at(self, value): def validate_schedule_at(self, value):
if value and not self.context['script'].scheduling_enabled: if value and not self.context['script'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this script.") raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value return value
def validate_interval(self, value): def validate_interval(self, value):
if value and not self.context['script'].scheduling_enabled: if value and not self.context['script'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this script.") raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value return value

View File

@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
@ -9,14 +8,13 @@ from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from rq import Worker from rq import Worker
from core.choices import JobStatusChoices
from core.models import Job from core.models import Job
from extras import filtersets from extras import filtersets
from extras.models import * from extras.models import *
from extras.scripts import get_module_and_script, run_script from extras.scripts import run_script
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
@ -209,66 +207,30 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
# Scripts # Scripts
# #
class ScriptViewSet(ViewSet): class ScriptViewSet(ModelViewSet):
permission_classes = [IsAuthenticatedOrLoginNotRequired] permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = Script.objects.prefetch_related('jobs')
serializer_class = serializers.ScriptSerializer
filterset_class = filtersets.ScriptFilterSet
_ignore_model_permissions = True _ignore_model_permissions = True
schema = None
lookup_value_regex = '[^/]+' # Allow dots lookup_value_regex = '[^/]+' # Allow dots
def _get_script(self, pk):
try:
module_name, script_name = pk.split('.', maxsplit=1)
except ValueError:
raise Http404
module, script = get_module_and_script(module_name, script_name)
if script is None:
raise Http404
return module, script
def list(self, request):
results = {
job.name: job
for job in Job.objects.filter(
object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).order_by('name', '-created').distinct('name').defer('data')
}
script_list = []
for script_module in ScriptModule.objects.restrict(request.user):
script_list.extend(script_module.scripts.values())
# Attach Job objects to each script (if any)
for script in script_list:
script.result = results.get(script.class_name, None)
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
return Response({'count': len(script_list), 'results': serializer.data})
def retrieve(self, request, pk): def retrieve(self, request, pk):
module, script = self._get_script(pk) script = get_object_or_404(self.queryset, pk=pk)
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
script.result = Job.objects.filter(
object_type=object_type,
name=script.class_name,
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first()
serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
return Response(serializer.data) return Response(serializer.data)
def post(self, request, pk): def post(self, request, pk):
""" """
Run a Script identified as "<module>.<script>" and return the pending Job as the result Run a Script identified by the id and return the pending Job as the result
""" """
if not request.user.has_perm('extras.run_script'): if not request.user.has_perm('extras.run_script'):
raise PermissionDenied("This user does not have permission to run scripts.") raise PermissionDenied("This user does not have permission to run scripts.")
module, script = self._get_script(pk) script = get_object_or_404(self.queryset, pk=pk)
input_serializer = serializers.ScriptInputSerializer( input_serializer = serializers.ScriptInputSerializer(
data=request.data, data=request.data,
context={'script': script} context={'script': script}
@ -281,13 +243,13 @@ class ScriptViewSet(ViewSet):
if input_serializer.is_valid(): if input_serializer.is_valid():
script.result = Job.enqueue( script.result = Job.enqueue(
run_script, run_script,
instance=module, instance=script.module,
name=script.class_name, name=script.python_class.class_name,
user=request.user, user=request.user,
data=input_serializer.data['data'], data=input_serializer.data['data'],
request=copy_safe_request(request), request=copy_safe_request(request),
commit=input_serializer.data['commit'], commit=input_serializer.data['commit'],
job_timeout=script.job_timeout, job_timeout=script.python_class.job_timeout,
schedule_at=input_serializer.validated_data.get('schedule_at'), schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval') interval=input_serializer.validated_data.get('interval')
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ from django.utils.translation import gettext as _
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.models import Job from core.models import Job
from extras.choices import LogLevelChoices from extras.choices import LogLevelChoices
from extras.models import ScriptModule from extras.models import ScriptModule, Script as ScriptModel
from extras.signals import clear_events from extras.signals import clear_events
from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@ -411,11 +411,11 @@ class BaseScript:
fieldsets.extend(self.fieldsets) fieldsets.extend(self.fieldsets)
else: else:
fields = list(name for name, _ in self._get_vars().items()) fields = list(name for name, _ in self._get_vars().items())
fieldsets.append(('Script Data', fields)) fieldsets.append((_('Script Data'), fields))
# Append the default fieldset if defined in the Meta class # Append the default fieldset if defined in the Meta class
exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',) exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
fieldsets.append(('Script Execution Parameters', exec_parameters)) fieldsets.append((_('Script Execution Parameters'), exec_parameters))
return fieldsets return fieldsets
@ -582,7 +582,7 @@ def is_variable(obj):
def get_module_and_script(module_name, script_name): def get_module_and_script(module_name, script_name):
module = ScriptModule.objects.get(file_path=f'{module_name}.py') module = ScriptModule.objects.get(file_path=f'{module_name}.py')
script = module.scripts.get(script_name) script = module.scripts.get(name=script_name)
return module, script return module, script
@ -599,8 +599,7 @@ def run_script(data, job, request=None, commit=True, **kwargs):
""" """
job.start() job.start()
module = ScriptModule.objects.get(pk=job.object_id) script = ScriptModel.objects.get(pk=job.object_id).python_class()
script = module.scripts.get(job.name)()
logger = logging.getLogger(f"netbox.scripts.{script.full_name}") logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
logger.info(f"Running script (commit={commit})") logger.info(f"Running script (commit={commit})")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,6 +33,7 @@ class ASNRangeSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags', 'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'asn_count', 'custom_fields', 'created', 'last_updated', 'asn_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -54,6 +55,7 @@ class ASNSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'site_count', 'provider_count', 'created', 'last_updated', 'site_count', 'provider_count',
] ]
brief_fields = ('id', 'url', 'display', 'asn', 'description')
class AvailableASNSerializer(serializers.Serializer): class AvailableASNSerializer(serializers.Serializer):
@ -104,6 +106,7 @@ class VRFSerializer(NetBoxModelSerializer):
'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count',
'prefix_count', 'prefix_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'rd', 'description', 'prefix_count')
# #
@ -120,6 +123,7 @@ class RouteTargetSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description')
# #
@ -138,6 +142,7 @@ class RIRSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'aggregate_count', 'last_updated', 'aggregate_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'aggregate_count')
class AggregateSerializer(NetBoxModelSerializer): class AggregateSerializer(NetBoxModelSerializer):
@ -153,6 +158,7 @@ class AggregateSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description')
# #
@ -169,6 +175,7 @@ class FHRPGroupSerializer(NetBoxModelSerializer):
'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments', 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses', 'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses',
] ]
brief_fields = ('id', 'url', 'display', 'protocol', 'group_id', 'description')
class FHRPGroupAssignmentSerializer(NetBoxModelSerializer): class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
@ -185,6 +192,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created', 'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created',
'last_updated', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority')
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_interface(self, obj): def get_interface(self, obj):
@ -212,6 +220,7 @@ class RoleSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'prefix_count', 'vlan_count', 'last_updated', 'prefix_count', 'vlan_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count')
class VLANGroupSerializer(NetBoxModelSerializer): class VLANGroupSerializer(NetBoxModelSerializer):
@ -237,6 +246,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid', 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
validators = [] validators = []
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
@ -267,6 +277,7 @@ class VLANSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description',
'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count',
] ]
brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')
class AvailableVLANSerializer(serializers.Serializer): class AvailableVLANSerializer(serializers.Serializer):
@ -327,6 +338,7 @@ class PrefixSerializer(NetBoxModelSerializer):
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
'_depth', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
class PrefixLengthSerializer(serializers.Serializer): class PrefixLengthSerializer(serializers.Serializer):
@ -397,6 +409,7 @@ class IPRangeSerializer(NetBoxModelSerializer):
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description')
# #
@ -427,6 +440,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments', 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'family', 'address', 'description')
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_assigned_object(self, obj): def get_assigned_object(self, obj):
@ -469,9 +483,10 @@ class ServiceTemplateSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = ServiceTemplate model = ServiceTemplate
fields = [ fields = [
'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')
class ServiceSerializer(NetBoxModelSerializer): class ServiceSerializer(NetBoxModelSerializer):
@ -489,6 +504,7 @@ class ServiceSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Service model = Service
fields = [ fields = [
'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')

View File

@ -3,6 +3,7 @@ from copy import deepcopy
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from netaddr import IPSet from netaddr import IPSet
@ -354,7 +355,7 @@ class AvailablePrefixesView(AvailableObjectsView):
'vrf': parent.vrf.pk if parent.vrf else None, 'vrf': parent.vrf.pk if parent.vrf else None,
}) })
else: else:
raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)") raise ValidationError(_("Insufficient space is available to accommodate the requested prefix size(s)"))
return requested_objects return requested_objects

View File

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

View File

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

View File

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

View File

@ -756,4 +756,4 @@ class ServiceCreateForm(ServiceForm):
if not self.cleaned_data['description']: if not self.cleaned_data['description']:
self.cleaned_data['description'] = service_template.description self.cleaned_data['description'] = service_template.description
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')): elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.") raise forms.ValidationError(_("Must specify name, protocol, and port(s) if not using a service template."))

View File

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

View File

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

View File

@ -1,4 +1,5 @@
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from netaddr import IPNetwork from netaddr import IPNetwork
@ -59,11 +60,13 @@ class ChoiceField(serializers.Field):
if data == '': if data == '':
if self.allow_blank: if self.allow_blank:
return data return data
raise ValidationError("This field may not be blank.") raise ValidationError(_("This field may not be blank."))
# Provide an explicit error message if the request is trying to write a dict or list # Provide an explicit error message if the request is trying to write a dict or list
if isinstance(data, (dict, list)): if isinstance(data, (dict, list)):
raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.') raise ValidationError(
_('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
)
# Check for string representations of boolean/integer values # Check for string representations of boolean/integer values
if hasattr(data, 'lower'): if hasattr(data, 'lower'):
@ -83,7 +86,7 @@ class ChoiceField(serializers.Field):
except TypeError: # Input is an unhashable type except TypeError: # Input is an unhashable type
pass pass
raise ValidationError(f"{data} is not a valid choice.") raise ValidationError(_("{value} is not a valid choice.").format(value=data))
@property @property
def choices(self): def choices(self):
@ -96,8 +99,8 @@ class ContentTypeField(RelatedField):
Represent a ContentType as '<app_label>.<model>' Represent a ContentType as '<app_label>.<model>'
""" """
default_error_messages = { default_error_messages = {
"does_not_exist": "Invalid content type: {content_type}", "does_not_exist": _("Invalid content type: {content_type}"),
"invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.", "invalid": _("Invalid value. Specify a content type as '<app_label>.<model_name>'."),
} }
def to_internal_value(self, data): def to_internal_value(self, data):

View File

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

View File

@ -34,6 +34,8 @@ class BaseViewSet(GenericViewSet):
""" """
Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions. Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
""" """
brief = False
def initial(self, request, *args, **kwargs): def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs) super().initial(request, *args, **kwargs)
@ -42,6 +44,13 @@ class BaseViewSet(GenericViewSet):
if action := HTTP_ACTIONS[request.method]: if action := HTTP_ACTIONS[request.method]:
self.queryset = self.queryset.restrict(request.user, action) self.queryset = self.queryset.restrict(request.user, action)
def initialize_request(self, request, *args, **kwargs):
# Annotate whether brief mode is active
self.brief = request.method == 'GET' and request.GET.get('brief')
return super().initialize_request(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
serializer_class = self.get_serializer_class() serializer_class = self.get_serializer_class()
@ -66,12 +75,17 @@ class BaseViewSet(GenericViewSet):
@cached_property @cached_property
def requested_fields(self): def requested_fields(self):
requested_fields = self.request.query_params.get('fields') # An explicit list of fields was requested
return requested_fields.split(',') if requested_fields else [] if requested_fields := self.request.query_params.get('fields'):
return requested_fields.split(',')
# Brief mode has been enabled for this request
elif self.brief:
serializer_class = self.get_serializer_class()
return getattr(serializer_class.Meta, 'brief_fields', None)
return None
class NetBoxReadOnlyModelViewSet( class NetBoxReadOnlyModelViewSet(
mixins.BriefModeMixin,
mixins.CustomFieldsMixin, mixins.CustomFieldsMixin,
mixins.ExportTemplatesMixin, mixins.ExportTemplatesMixin,
drf_mixins.RetrieveModelMixin, drf_mixins.RetrieveModelMixin,
@ -85,7 +99,6 @@ class NetBoxModelViewSet(
mixins.BulkUpdateModelMixin, mixins.BulkUpdateModelMixin,
mixins.BulkDestroyModelMixin, mixins.BulkDestroyModelMixin,
mixins.ObjectValidationMixin, mixins.ObjectValidationMixin,
mixins.BriefModeMixin,
mixins.CustomFieldsMixin, mixins.CustomFieldsMixin,
mixins.ExportTemplatesMixin, mixins.ExportTemplatesMixin,
drf_mixins.CreateModelMixin, drf_mixins.CreateModelMixin,

View File

@ -1,5 +1,3 @@
import logging
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
@ -8,13 +6,9 @@ from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from extras.models import ExportTemplate from extras.models import ExportTemplate
from netbox.api.exceptions import SerializerNotFound
from netbox.api.serializers import BulkOperationSerializer from netbox.api.serializers import BulkOperationSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
__all__ = ( __all__ = (
'BriefModeMixin',
'BulkDestroyModelMixin', 'BulkDestroyModelMixin',
'BulkUpdateModelMixin', 'BulkUpdateModelMixin',
'CustomFieldsMixin', 'CustomFieldsMixin',
@ -24,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: class CustomFieldsMixin:
""" """
For models which support custom fields, populate the `custom_fields` context. For models which support custom fields, populate the `custom_fields` context.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@ import datetime
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import Client from django.test import Client
from django.test.utils import override_settings from django.test.utils import override_settings
@ -12,7 +11,7 @@ from rest_framework.test import APIClient
from dcim.models import Site from dcim.models import Site
from ipam.models import Prefix 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 import TestCase
from utilities.testing.api import APITestCase from utilities.testing.api import APITestCase

View File

@ -20,6 +20,10 @@ class PluginTest(TestCase):
self.assertIn('netbox.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS) 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): def test_models(self):
from netbox.tests.dummy_plugin.models import DummyModel from netbox.tests.dummy_plugin.models import DummyModel

View File

@ -14,6 +14,7 @@ from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django_tables2.export import TableExport from django_tables2.export import TableExport
from extras.models import ExportTemplate from extras.models import ExportTemplate
@ -319,7 +320,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
if type(field.widget) is not HiddenInput 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 # Save the primary object
obj = self.save_object(model_form, request) obj = self.save_object(model_form, request)
@ -344,11 +345,14 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
related_obj = f.save() related_obj = f.save()
related_obj_pks.append(related_obj.pk) related_obj_pks.append(related_obj.pk)
else: 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 subfield_name, errors in f.errors.items():
for err in errors: for err in errors:
err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err) if subfield_name == '__all__':
model_form.add_error(None, err_msg) 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() raise AbortTransaction()
# Enforce object-level permissions on related objects # Enforce object-level permissions on related objects
@ -389,7 +393,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
try: try:
instance = prefetched_objects[object_id] instance = prefetched_objects[object_id]
except KeyError: 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('') raise ValidationError('')
# Take a snapshot for change logging # Take a snapshot for change logging
@ -415,7 +419,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
restrict_form_fields(model_form, request.user) restrict_form_fields(model_form, request.user)
if model_form.is_valid(): if model_form.is_valid():
obj = self._save_object(model_form, request) obj = self._save_object(form, model_form, request)
saved_objects.append(obj) saved_objects.append(obj)
else: else:
# Replicate model form errors for display # Replicate model form errors for display

View File

@ -11,6 +11,7 @@ from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from extras.signals import clear_events from extras.signals import clear_events
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
@ -100,7 +101,9 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
request: The current request request: The current request
parent: The parent object 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): def prep_table_data(self, request, queryset, parent):
""" """

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 B

View File

@ -18,11 +18,12 @@ function handleSelection(link: HTMLAnchorElement): void {
const value = link.getAttribute('data-value'); const value = link.getAttribute('data-value');
//@ts-ignore //@ts-ignore
target.slim.setData([ target.tomselect.addOption({
{text: label, value: value} id: value,
]); display: label,
const change = new Event('change'); });
target.dispatchEvent(change); //@ts-ignore
target.tomselect.addItem(value);
} }

View File

@ -42,7 +42,7 @@ function renderItem(data: TomOption, escape: typeof escape_html) {
// Initialize <select> elements which are populated via a REST API call // Initialize <select> elements which are populated via a REST API call
export function initDynamicSelects(): void { 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, { new DynamicTomSelect(select, {
...config, ...config,
valueField: VALUE_FIELD, valueField: VALUE_FIELD,

View File

@ -7,10 +7,11 @@ import { getElements } from '../util';
// Initialize <select> elements with statically-defined options // Initialize <select> elements with statically-defined options
export function initStaticSelects(): void { export function initStaticSelects(): void {
for (const select of getElements<HTMLSelectElement>( 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, { new TomSelect(select, {
...config, ...config,
maxOptions: undefined,
}); });
} }
} }
@ -23,9 +24,10 @@ export function initColorSelects(): void {
)}"></span> ${escape(item.text)}</div>`; )}"></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, { new TomSelect(select, {
...config, ...config,
maxOptions: undefined,
render: { render: {
option: renderColor, option: renderColor,
item: renderColor, item: renderColor,

View File

@ -244,29 +244,6 @@ export function getSelectedOptions<E extends HTMLElement>(
return selected; 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. * Toggle visibility of an element.
*/ */

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