Merge branch 'netbox-community:develop' into develop
This commit is contained in:
commit
6c06d31a80
|
@ -13,7 +13,9 @@ body:
|
|||
- type: dropdown
|
||||
attributes:
|
||||
label: Deployment Type
|
||||
description: How are you running NetBox?
|
||||
description: >
|
||||
How are you running NetBox? (For issues with the Docker image, please go to the
|
||||
[netbox-docker](https://github.com/netbox-community/netbox-docker) repo.)
|
||||
options:
|
||||
- Self-hosted
|
||||
- NetBox Cloud
|
||||
|
@ -23,7 +25,7 @@ body:
|
|||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.2
|
||||
placeholder: v3.7.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
@ -14,7 +14,7 @@ body:
|
|||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.2
|
||||
placeholder: v3.7.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-6-blue" alt="Languages supported" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-7-blue" alt="Languages supported" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
|
||||
<p></p>
|
||||
</div>
|
||||
|
|
|
@ -105,7 +105,7 @@ mkdocs-material
|
|||
mkdocstrings[python-legacy]
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||
netaddr
|
||||
|
||||
# Fork of PIL (Python Imaging Library) for image processing
|
||||
|
|
|
@ -31,8 +31,7 @@ This section entails the installation and configuration of a local PostgreSQL da
|
|||
Once PostgreSQL has been installed, start the service and enable it to run at boot:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl start postgresql
|
||||
sudo systemctl enable postgresql
|
||||
sudo systemctl enable --now postgresql
|
||||
```
|
||||
|
||||
Before continuing, verify that you have installed PostgreSQL 12 or later:
|
||||
|
|
|
@ -14,8 +14,7 @@
|
|||
|
||||
```no-highlight
|
||||
sudo yum install -y redis
|
||||
sudo systemctl start redis
|
||||
sudo systemctl enable redis
|
||||
sudo systemctl enable --now redis
|
||||
```
|
||||
|
||||
Before continuing, verify that your installed version of Redis is at least v4.0:
|
||||
|
|
|
@ -27,8 +27,7 @@ sudo systemctl daemon-reload
|
|||
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl start netbox netbox-rq
|
||||
sudo systemctl enable netbox netbox-rq
|
||||
sudo systemctl enable --now netbox netbox-rq
|
||||
```
|
||||
|
||||
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
|
||||
|
|
|
@ -1,17 +1,55 @@
|
|||
# NetBox v3.7
|
||||
|
||||
## v3.7.3 (FUTURE)
|
||||
## v3.7.4 (FUTURE)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#15291](https://github.com/netbox-community/netbox/issues/15291) - Add tunnel termination buttons to VM interfaces table
|
||||
* [#15297](https://github.com/netbox-community/netbox/issues/15297) - Linkify platform column in device & virtual machine tables
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#15220](https://github.com/netbox-community/netbox/issues/15220) - Fix validation check when bulk editing the mask length of IP addresses
|
||||
* [#15232](https://github.com/netbox-community/netbox/issues/15232) - Permit user with sufficient permissions to assign an inventory item to a device type
|
||||
* [#15241](https://github.com/netbox-community/netbox/issues/15241) - Restore missing `display` field on VirtualDisk serialization in REST API
|
||||
* [#15243](https://github.com/netbox-community/netbox/issues/15243) - Correct representation of installed module when listing module bays using REST API brief mode
|
||||
* [#15316](https://github.com/netbox-community/netbox/issues/15316) - Fix selection of 3DES encryption for IKE & IPSec proposals
|
||||
* [#15336](https://github.com/netbox-community/netbox/issues/15336) - Correct label for recurring scheduled jobs
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -234,9 +234,9 @@ class CircuitTermination(
|
|||
|
||||
# Must define either site *or* provider network
|
||||
if self.site is None and self.provider_network is None:
|
||||
raise ValidationError("A circuit termination must attach to either a site or a provider network.")
|
||||
raise ValidationError(_("A circuit termination must attach to either a site or a provider network."))
|
||||
if self.site and self.provider_network:
|
||||
raise ValidationError("A circuit termination cannot attach to both a site and a provider network.")
|
||||
raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network."))
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
|
|
@ -102,7 +102,7 @@ class GitBackend(DataBackend):
|
|||
try:
|
||||
porcelain.clone(self.url, local_path.name, **clone_args)
|
||||
except BaseException as e:
|
||||
raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")
|
||||
raise SyncError(_("Fetching remote data failed ({name}): {error}").format(name=type(e).__name__, error=e))
|
||||
|
||||
yield local_path.name
|
||||
|
||||
|
|
|
@ -103,9 +103,9 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
|
|||
super().clean()
|
||||
|
||||
if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'):
|
||||
raise forms.ValidationError("Cannot upload a file and sync from an existing file")
|
||||
raise forms.ValidationError(_("Cannot upload a file and sync from an existing file"))
|
||||
if not self.cleaned_data.get('upload_file') and not self.cleaned_data.get('data_file'):
|
||||
raise forms.ValidationError("Must upload a file or select a data file to sync")
|
||||
raise forms.ValidationError(_("Must upload a file or select a data file to sync"))
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ class ConfigRevision(models.Model):
|
|||
return gettext('Config revision #{id}').format(id=self.pk)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.data:
|
||||
if self.data and item in self.data:
|
||||
return self.data[item]
|
||||
return super().__getattribute__(item)
|
||||
|
||||
|
|
|
@ -177,7 +177,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||
Create/update/delete child DataFiles as necessary to synchronize with the remote source.
|
||||
"""
|
||||
if self.status == DataSourceStatusChoices.SYNCING:
|
||||
raise SyncError("Cannot initiate sync; syncing already in progress.")
|
||||
raise SyncError(_("Cannot initiate sync; syncing already in progress."))
|
||||
|
||||
# Emit the pre_sync signal
|
||||
pre_sync.send(sender=self.__class__, instance=self)
|
||||
|
@ -190,7 +190,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||
backend = self.get_backend()
|
||||
except ModuleNotFoundError as e:
|
||||
raise SyncError(
|
||||
f"There was an error initializing the backend. A dependency needs to be installed: {e}"
|
||||
_("There was an error initializing the backend. A dependency needs to be installed: ") + str(e)
|
||||
)
|
||||
with backend.fetch() as local_path:
|
||||
|
||||
|
|
|
@ -181,7 +181,11 @@ class Job(models.Model):
|
|||
"""
|
||||
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
if status not in valid_statuses:
|
||||
raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}")
|
||||
raise ValueError(
|
||||
_("Invalid status for job termination. Choices are: {choices}").format(
|
||||
choices=', '.join(valid_statuses)
|
||||
)
|
||||
)
|
||||
|
||||
# Mark the job as completed
|
||||
self.status = status
|
||||
|
|
|
@ -166,7 +166,7 @@ class ConfigView(generic.ObjectView):
|
|||
except ConfigRevision.DoesNotExist:
|
||||
# Fall back to using the active config data if no record is found
|
||||
return ConfigRevision(
|
||||
data=get_config()
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -414,11 +414,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
|
|||
|
||||
class NestedModuleBaySerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
|
||||
module = NestedModuleSerializer(required=False, read_only=True, allow_null=True)
|
||||
installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ModuleBay
|
||||
fields = ['id', 'url', 'display', 'module', 'name']
|
||||
fields = ['id', 'url', 'display', 'installed_module', 'name']
|
||||
|
||||
|
||||
class NestedDeviceBaySerializer(WritableNestedSerializer):
|
||||
|
|
|
@ -326,6 +326,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
|||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
|
||||
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
front_image = serializers.URLField(allow_null=True, required=False)
|
||||
rear_image = serializers.URLField(allow_null=True, required=False)
|
||||
|
||||
# Counter fields
|
||||
console_port_template_count = serializers.IntegerField(read_only=True)
|
||||
|
@ -1037,8 +1039,7 @@ class ModuleBaySerializer(NetBoxModelSerializer):
|
|||
model = ModuleBay
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
|
||||
'custom_fields',
|
||||
'created', 'last_updated',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -191,6 +191,12 @@ class RackViewSet(NetBoxModelViewSet):
|
|||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filtersets.RackFilterSet
|
||||
|
||||
@extend_schema(
|
||||
operation_id='dcim_racks_elevation_retrieve',
|
||||
filters=False,
|
||||
parameters=[serializers.RackElevationDetailFilterSerializer],
|
||||
responses={200: serializers.RackUnitSerializer(many=True)}
|
||||
)
|
||||
@action(detail=True)
|
||||
def elevation(self, request, pk=None):
|
||||
"""
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
|
||||
|
||||
from .lookups import PathContains
|
||||
|
@ -41,7 +42,7 @@ class MACAddressField(models.Field):
|
|||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
raise ValidationError(f"Invalid MAC address format: {value}")
|
||||
raise ValidationError(_("Invalid MAC address format: {value}").format(value=value))
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'macaddr'
|
||||
|
@ -67,7 +68,7 @@ class WWNField(models.Field):
|
|||
try:
|
||||
return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
raise ValidationError(f"Invalid WWN format: {value}")
|
||||
raise ValidationError(_("Invalid WWN format: {value}").format(value=value))
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'macaddr8'
|
||||
|
|
|
@ -2,6 +2,8 @@ import django_filters
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
|
||||
from circuits.models import CircuitTermination
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
|
@ -818,6 +820,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
|||
to_field_name='slug',
|
||||
label=_('Manufacturer (slug)'),
|
||||
)
|
||||
available_for_device_type = django_filters.ModelChoiceFilter(
|
||||
queryset=DeviceType.objects.all(),
|
||||
method='get_for_device_type'
|
||||
)
|
||||
config_template_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
label=_('Config template (ID)'),
|
||||
|
@ -827,6 +833,14 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
|||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_for_device_type(self, queryset, name, value):
|
||||
"""
|
||||
Return all Platforms available for a specific manufacturer based on device type and Platforms not assigned any
|
||||
manufacturer
|
||||
"""
|
||||
return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
|
||||
|
||||
|
||||
class DeviceFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
|
|
|
@ -159,6 +159,14 @@ class LocationImportForm(NetBoxModelImportForm):
|
|||
model = Location
|
||||
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
# Limit location queryset by assigned site
|
||||
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
|
||||
|
||||
class RackRoleImportForm(NetBoxModelImportForm):
|
||||
slug = SlugField()
|
||||
|
@ -870,7 +878,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||
def clean_vdcs(self):
|
||||
for vdc in self.cleaned_data['vdcs']:
|
||||
if vdc.device != self.cleaned_data['device']:
|
||||
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
|
||||
raise forms.ValidationError(
|
||||
_("VDC {vdc} is not assigned to device {device}").format(
|
||||
vdc=vdc, device=self.cleaned_data['device']
|
||||
)
|
||||
)
|
||||
return self.cleaned_data['vdcs']
|
||||
|
||||
|
||||
|
@ -1075,7 +1087,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
|||
component = model.objects.get(device=device, name=component_name)
|
||||
self.instance.component = component
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Component not found: {device} - {component_name}")
|
||||
raise forms.ValidationError(
|
||||
_("Component not found: {device} - {component_name}").format(
|
||||
device=device, component_name=component_name
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
|
@ -1193,10 +1209,17 @@ class CableImportForm(NetBoxModelImportForm):
|
|||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None and termination_object.cable != self.instance:
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
raise forms.ValidationError(
|
||||
_("Side {side_upper}: {device} {termination_object} is already connected").format(
|
||||
side_upper=side.upper(), device=device, termination_object=termination_object
|
||||
)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
raise forms.ValidationError(
|
||||
_("{side_upper} side termination not found: {device} {name}").format(
|
||||
side_upper=side.upper(), device=device, name=name
|
||||
)
|
||||
)
|
||||
setattr(self.instance, f'{side}_terminations', [termination_object])
|
||||
return termination_object
|
||||
|
||||
|
|
|
@ -291,7 +291,11 @@ class DeviceTypeForm(NetBoxModelForm):
|
|||
default_platform = DynamicModelChoiceField(
|
||||
label=_('Default platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True,
|
||||
query_params={
|
||||
'manufacturer_id': ['$manufacturer', 'null'],
|
||||
}
|
||||
)
|
||||
slug = SlugField(
|
||||
label=_('Slug'),
|
||||
|
@ -444,7 +448,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
selector=True
|
||||
selector=True,
|
||||
query_params={
|
||||
'available_for_device_type': '$device_type',
|
||||
}
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
|
|
|
@ -160,25 +160,26 @@ class Cable(PrimaryModel):
|
|||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
raise ValidationError(_("Must specify a unit when setting a cable length"))
|
||||
|
||||
if self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||
raise ValidationError("Must define A and B terminations when creating a new cable.")
|
||||
raise ValidationError(_("Must define A and B terminations when creating a new cable."))
|
||||
|
||||
if self._terminations_modified:
|
||||
|
||||
# Check that all termination objects for either end are of the same type
|
||||
for terms in (self.a_terminations, self.b_terminations):
|
||||
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
|
||||
raise ValidationError("Cannot connect different termination types to same end of cable.")
|
||||
raise ValidationError(_("Cannot connect different termination types to same end of cable."))
|
||||
|
||||
# Check that termination types are compatible
|
||||
if self.a_terminations and self.b_terminations:
|
||||
a_type = self.a_terminations[0]._meta.model_name
|
||||
b_type = self.b_terminations[0]._meta.model_name
|
||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||
|
||||
raise ValidationError(
|
||||
_("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type)
|
||||
)
|
||||
if a_type == b_type:
|
||||
# can't directly use self.a_terminations here as possible they
|
||||
# don't have pk yet
|
||||
|
@ -323,17 +324,24 @@ class CableTermination(ChangeLoggedModel):
|
|||
).first()
|
||||
if existing_termination is not None:
|
||||
raise ValidationError(
|
||||
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
|
||||
f"{self.termination_id}: cable {existing_termination.cable.pk}"
|
||||
_("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}".format(
|
||||
app_label=self.termination_type.app_label,
|
||||
model=self.termination_type.model,
|
||||
termination_id=self.termination_id,
|
||||
cable_pk=existing_termination.cable.pk
|
||||
))
|
||||
)
|
||||
|
||||
# Validate interface type (if applicable)
|
||||
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
|
||||
raise ValidationError(
|
||||
_("Cables cannot be terminated to {type_display} interfaces").format(
|
||||
type_display=self.termination.get_type_display()
|
||||
)
|
||||
)
|
||||
|
||||
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
|
||||
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
|
||||
raise ValidationError("Circuit terminations attached to a provider network may not be cabled.")
|
||||
raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
|
|
|
@ -210,6 +210,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||
linkify=True,
|
||||
verbose_name=_('Type')
|
||||
)
|
||||
platform = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Platform')
|
||||
)
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('primary_ip4', 'primary_ip6'),
|
||||
|
@ -294,7 +298,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||
model = models.Device
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type',
|
||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||
'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
||||
'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
|
@ -359,6 +363,11 @@ class CableTerminationTable(NetBoxTable):
|
|||
verbose_name=_('Mark Connected'),
|
||||
)
|
||||
|
||||
def value_link_peer(self, value):
|
||||
return ', '.join([
|
||||
f"{termination.parent_object} > {termination}" for termination in value
|
||||
])
|
||||
|
||||
|
||||
class PathEndpointTable(CableTerminationTable):
|
||||
connection = columns.TemplateColumn(
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.choices import *
|
||||
|
@ -45,7 +46,7 @@ class Mixins:
|
|||
name='Peer Device'
|
||||
)
|
||||
if self.peer_termination_type is None:
|
||||
raise NotImplementedError("Test case must set peer_termination_type")
|
||||
raise NotImplementedError(_("Test case must set peer_termination_type"))
|
||||
peer_obj = self.peer_termination_type.objects.create(
|
||||
device=peer_device,
|
||||
name='Peer Termination'
|
||||
|
@ -1754,7 +1755,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
|
|||
|
||||
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ModuleBay
|
||||
brief_fields = ['display', 'id', 'module', 'name', 'url']
|
||||
brief_fields = ['display', 'id', 'installed_module', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
|
|
@ -1787,6 +1787,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='foobar1'),
|
||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='foobar2'),
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
|
||||
Platform(name='Platform 4', slug='platform-4'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
|
||||
|
@ -1813,6 +1814,17 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_available_for_device_type(self):
|
||||
manufacturers = Manufacturer.objects.all()[:2]
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturers[0],
|
||||
model='Device Type 1',
|
||||
slug='device-type-1',
|
||||
u_height=1
|
||||
)
|
||||
params = {'available_for_device_type': device_type.pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Device.objects.all()
|
||||
|
|
|
@ -1079,7 +1079,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
|
|||
tab = ViewTab(
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventory_item_template_count,
|
||||
permission='dcim.view_invenotryitemtemplate',
|
||||
permission='dcim.view_inventoryitemtemplate',
|
||||
weight=590,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework.fields import Field
|
||||
|
@ -88,7 +89,7 @@ class CustomFieldsDataField(Field):
|
|||
if serializer.is_valid():
|
||||
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
|
||||
else:
|
||||
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
|
||||
raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
|
||||
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
if self.parent.instance:
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
@ -150,7 +151,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||
|
||||
def validate_type(self, value):
|
||||
if self.instance and self.instance.type != value:
|
||||
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
|
||||
raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
|
||||
|
||||
return value
|
||||
|
||||
|
@ -545,12 +546,12 @@ class ReportInputSerializer(serializers.Serializer):
|
|||
|
||||
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.")
|
||||
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.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
|
||||
return value
|
||||
|
||||
|
||||
|
@ -595,12 +596,12 @@ class ScriptInputSerializer(serializers.Serializer):
|
|||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this script.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this script.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import functools
|
||||
import re
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'Condition',
|
||||
|
@ -50,11 +51,13 @@ class Condition:
|
|||
|
||||
def __init__(self, attr, value, op=EQ, negate=False):
|
||||
if op not in self.OPERATORS:
|
||||
raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}")
|
||||
raise ValueError(_("Unknown operator: {op}. Must be one of: {operators}").format(
|
||||
op=op, operators=', '.join(self.OPERATORS)
|
||||
))
|
||||
if type(value) not in self.TYPES:
|
||||
raise ValueError(f"Unsupported value type: {type(value)}")
|
||||
raise ValueError(_("Unsupported value type: {value}").format(value=type(value)))
|
||||
if op not in self.TYPES[type(value)]:
|
||||
raise ValueError(f"Invalid type for {op} operation: {type(value)}")
|
||||
raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value)))
|
||||
|
||||
self.attr = attr
|
||||
self.value = value
|
||||
|
@ -131,14 +134,17 @@ class ConditionSet:
|
|||
"""
|
||||
def __init__(self, ruleset):
|
||||
if type(ruleset) is not dict:
|
||||
raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.")
|
||||
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
|
||||
if len(ruleset) != 1:
|
||||
raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})")
|
||||
raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
|
||||
ruleset=len(ruleset)))
|
||||
|
||||
# Determine the logic type
|
||||
logic = list(ruleset.keys())[0]
|
||||
if type(logic) is not str or logic.lower() not in (AND, OR):
|
||||
raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')")
|
||||
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
|
||||
logic=logic, op_and=AND, op_or=OR
|
||||
))
|
||||
self.logic = logic.lower()
|
||||
|
||||
# Compile the set of Conditions
|
||||
|
|
|
@ -2,6 +2,7 @@ import uuid
|
|||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from extras.constants import DEFAULT_DASHBOARD
|
||||
|
@ -32,7 +33,7 @@ def get_widget_class(name):
|
|||
try:
|
||||
return registry['widgets'][name]
|
||||
except KeyError:
|
||||
raise ValueError(f"Unregistered widget class: {name}")
|
||||
raise ValueError(_("Unregistered widget class: {name}").format(name=name))
|
||||
|
||||
|
||||
def get_dashboard(user):
|
||||
|
|
|
@ -112,7 +112,9 @@ class DashboardWidget:
|
|||
Params:
|
||||
request: The current request
|
||||
"""
|
||||
raise NotImplementedError(f"{self.__class__} must define a render() method.")
|
||||
raise NotImplementedError(_("{class_name} must define a render() method.").format(
|
||||
class_name=self.__class__
|
||||
))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@ -178,7 +180,7 @@ class ObjectCountsWidget(DashboardWidget):
|
|||
try:
|
||||
dict(data)
|
||||
except TypeError:
|
||||
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
|
||||
raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary."))
|
||||
return data
|
||||
|
||||
def render(self, request):
|
||||
|
@ -232,7 +234,7 @@ class ObjectListWidget(DashboardWidget):
|
|||
try:
|
||||
urlencode(data)
|
||||
except (TypeError, ValueError):
|
||||
raise forms.ValidationError("Invalid format. URL parameters must be passed as a dictionary.")
|
||||
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
|
||||
return data
|
||||
|
||||
def render(self, request):
|
||||
|
|
|
@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django_rq import get_queue
|
||||
|
||||
from core.models import Job
|
||||
|
@ -129,7 +130,9 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
|
|||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown action type for an event rule: {event_rule.action_type}")
|
||||
raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
|
||||
action_type=event_rule.action_type
|
||||
))
|
||||
|
||||
|
||||
def process_event_queue(events):
|
||||
|
@ -175,4 +178,4 @@ def flush_events(queue):
|
|||
func = import_string(name)
|
||||
func(queue)
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot import events pipeline {name} error: {e}")
|
||||
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
||||
|
|
|
@ -202,7 +202,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
|
|||
try:
|
||||
webhook = Webhook.objects.get(name=action_object)
|
||||
except Webhook.DoesNotExist:
|
||||
raise forms.ValidationError(f"Webhook {action_object} not found")
|
||||
raise forms.ValidationError(_("Webhook {name} not found").format(name=action_object))
|
||||
self.instance.action_object = webhook
|
||||
# Script
|
||||
elif action_type == EventRuleActionChoices.SCRIPT:
|
||||
|
@ -211,7 +211,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
|
|||
try:
|
||||
module, script = get_module_and_script(module_name, script_name)
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Script {action_object} not found")
|
||||
raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
|
||||
self.instance.action_object = module
|
||||
self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
|
||||
self.instance.action_parameters = {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from netbox.search.backends import search_backend
|
||||
|
@ -62,7 +63,7 @@ class Command(BaseCommand):
|
|||
# Determine which models to reindex
|
||||
indexers = self._get_indexers(*model_labels)
|
||||
if not indexers:
|
||||
raise CommandError("No indexers found!")
|
||||
raise CommandError(_("No indexers found!"))
|
||||
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
||||
|
||||
# Clear all cached values for the specified models (if not being lazy)
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 4.2.9 on 2024-02-20 17:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0106_bookmark_user_cascade_deletion'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='cachedvalue',
|
||||
index=models.Index(fields=['object_type', 'object_id'], name='extras_cachedvalue_object'),
|
||||
),
|
||||
]
|
|
@ -57,6 +57,9 @@ class CachedValue(models.Model):
|
|||
ordering = ('weight', 'object_type', 'value', 'object_id')
|
||||
verbose_name = _('cached value')
|
||||
verbose_name_plural = _('cached values')
|
||||
indexes = (
|
||||
models.Index(fields=('object_type', 'object_id'), name='extras_cachedvalue_object'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
|
||||
|
|
|
@ -11,6 +11,7 @@ from django.conf import settings
|
|||
from django.core.validators import RegexValidator
|
||||
from django.db import transaction
|
||||
from django.utils.functional import classproperty
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
|
@ -356,7 +357,7 @@ class BaseScript:
|
|||
return ordered_vars
|
||||
|
||||
def run(self, data, commit):
|
||||
raise NotImplementedError("The script must define a run() method.")
|
||||
raise NotImplementedError(_("The script must define a run() method."))
|
||||
|
||||
# Form rendering
|
||||
|
||||
|
@ -367,11 +368,11 @@ class BaseScript:
|
|||
fieldsets.extend(self.fieldsets)
|
||||
else:
|
||||
fields = list(name for name, _ in self._get_vars().items())
|
||||
fieldsets.append(('Script Data', fields))
|
||||
fieldsets.append((_('Script Data'), fields))
|
||||
|
||||
# Append the default fieldset if defined in the Meta class
|
||||
exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
|
||||
fieldsets.append(('Script Execution Parameters', exec_parameters))
|
||||
fieldsets.append((_('Script Execution Parameters'), exec_parameters))
|
||||
|
||||
return fieldsets
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import importlib
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
@ -13,7 +12,7 @@ from core.signals import job_end, job_start
|
|||
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||
from extras.events import process_event_rules
|
||||
from extras.models import EventRule
|
||||
from extras.validators import CustomValidator
|
||||
from extras.validators import run_validators
|
||||
from netbox.config import get_config
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
|
@ -110,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
|
|||
"""
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
# Run any deletion protection rules for the object. Note that this must occur prior
|
||||
# to queueing any events for the object being deleted, in case a validation error is
|
||||
# raised, causing the deletion to fail.
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().PROTECTION_RULES.get(model_name, [])
|
||||
try:
|
||||
run_validators(instance, validators)
|
||||
except ValidationError as e:
|
||||
raise AbortRequest(
|
||||
_("Deletion is prevented by a protection rule: {message}").format(message=e)
|
||||
)
|
||||
|
||||
# Get the current request, or bail if not set
|
||||
request = current_request.get()
|
||||
if request is None:
|
||||
|
@ -207,45 +218,17 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
|
|||
# Custom validation
|
||||
#
|
||||
|
||||
def run_validators(instance, validators):
|
||||
|
||||
for validator in validators:
|
||||
|
||||
# Loading a validator class by dotted path
|
||||
if type(validator) is str:
|
||||
module, cls = validator.rsplit('.', 1)
|
||||
validator = getattr(importlib.import_module(module), cls)()
|
||||
|
||||
# Constructing a new instance on the fly from a ruleset
|
||||
elif type(validator) is dict:
|
||||
validator = CustomValidator(validator)
|
||||
|
||||
validator(instance)
|
||||
|
||||
|
||||
@receiver(post_clean)
|
||||
def run_save_validators(sender, instance, **kwargs):
|
||||
"""
|
||||
Run any custom validation rules for the model prior to calling save().
|
||||
"""
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
|
||||
|
||||
run_validators(instance, validators)
|
||||
|
||||
|
||||
@receiver(pre_delete)
|
||||
def run_delete_validators(sender, instance, **kwargs):
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().PROTECTION_RULES.get(model_name, [])
|
||||
|
||||
try:
|
||||
run_validators(instance, validators)
|
||||
except ValidationError as e:
|
||||
raise AbortRequest(
|
||||
_("Deletion is prevented by a protection rule: {message}").format(
|
||||
message=e
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import importlib
|
||||
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -149,3 +151,21 @@ class CustomValidator:
|
|||
if field is not None:
|
||||
raise ValidationError({field: message})
|
||||
raise ValidationError(message)
|
||||
|
||||
|
||||
def run_validators(instance, validators):
|
||||
"""
|
||||
Run the provided iterable of validators for the instance.
|
||||
"""
|
||||
for validator in validators:
|
||||
|
||||
# Loading a validator class by dotted path
|
||||
if type(validator) is str:
|
||||
module, cls = validator.rsplit('.', 1)
|
||||
validator = getattr(importlib.import_module(module), cls)()
|
||||
|
||||
# Constructing a new instance on the fly from a ruleset
|
||||
elif type(validator) is dict:
|
||||
validator = CustomValidator(validator)
|
||||
|
||||
validator(instance)
|
||||
|
|
|
@ -3,6 +3,7 @@ from copy import deepcopy
|
|||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from netaddr import IPSet
|
||||
|
@ -379,7 +380,7 @@ class AvailablePrefixesView(AvailableObjectsView):
|
|||
'vrf': parent.vrf.pk if parent.vrf else None,
|
||||
})
|
||||
else:
|
||||
raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)")
|
||||
raise ValidationError(_("Insufficient space is available to accommodate the requested prefix size(s)"))
|
||||
|
||||
return requested_objects
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from netaddr import AddrFormatError, IPNetwork
|
||||
|
||||
from . import lookups, validators
|
||||
|
@ -32,7 +33,7 @@ class BaseIPField(models.Field):
|
|||
# Always return a netaddr.IPNetwork object. (netaddr.IPAddress does not provide a mask.)
|
||||
return IPNetwork(value)
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Invalid IP address format: {}".format(value))
|
||||
raise ValidationError(_("Invalid IP address format: {address}").format(address=value))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_ipv4_address, validate_ipv6_address
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from netaddr import IPAddress, IPNetwork, AddrFormatError
|
||||
|
||||
|
||||
|
@ -10,7 +11,7 @@ from netaddr import IPAddress, IPNetwork, AddrFormatError
|
|||
|
||||
class IPAddressFormField(forms.Field):
|
||||
default_error_messages = {
|
||||
'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).",
|
||||
'invalid': _("Enter a valid IPv4 or IPv6 address (without a mask)."),
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
|
@ -28,19 +29,19 @@ class IPAddressFormField(forms.Field):
|
|||
try:
|
||||
validate_ipv6_address(value)
|
||||
except ValidationError:
|
||||
raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
|
||||
raise ValidationError(_("Invalid IPv4/IPv6 address format: {address}").format(address=value))
|
||||
|
||||
try:
|
||||
return IPAddress(value)
|
||||
except ValueError:
|
||||
raise ValidationError('This field requires an IP address without a mask.')
|
||||
raise ValidationError(_('This field requires an IP address without a mask.'))
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
|
||||
raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
|
||||
|
||||
|
||||
class IPNetworkFormField(forms.Field):
|
||||
default_error_messages = {
|
||||
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
|
||||
'invalid': _("Enter a valid IPv4 or IPv6 address (with CIDR mask)."),
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
|
@ -52,9 +53,9 @@ class IPNetworkFormField(forms.Field):
|
|||
|
||||
# Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128.
|
||||
if len(value.split('/')) != 2:
|
||||
raise ValidationError('CIDR mask (e.g. /24) is required.')
|
||||
raise ValidationError(_('CIDR mask (e.g. /24) is required.'))
|
||||
|
||||
try:
|
||||
return IPNetwork(value)
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
|
||||
raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
|
||||
|
|
|
@ -367,20 +367,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||
'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
|
||||
)
|
||||
|
||||
# Do not allow assigning a network ID or broadcast address to an interface.
|
||||
if interface and (address := self.cleaned_data.get('address')):
|
||||
if address.ip == address.network:
|
||||
msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(ip=address.ip)
|
||||
if address.version == 4 and address.prefixlen not in (31, 32):
|
||||
raise ValidationError(msg)
|
||||
if address.version == 6 and address.prefixlen not in (127, 128):
|
||||
raise ValidationError(msg)
|
||||
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
|
||||
msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
|
||||
ip=address.ip
|
||||
)
|
||||
raise ValidationError(msg)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
|
@ -751,4 +737,4 @@ class ServiceCreateForm(ServiceForm):
|
|||
if not self.cleaned_data['description']:
|
||||
self.cleaned_data['description'] = service_template.description
|
||||
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
|
||||
raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
|
||||
raise forms.ValidationError(_("Must specify name, protocol, and port(s) if not using a service template."))
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import graphene
|
||||
|
||||
from ipam import filtersets, models
|
||||
from .mixins import IPAddressesMixin
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
|
||||
|
@ -71,7 +72,7 @@ class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
|
|||
filterset_class = filtersets.AggregateFilterSet
|
||||
|
||||
|
||||
class FHRPGroupType(NetBoxObjectType):
|
||||
class FHRPGroupType(NetBoxObjectType, IPAddressesMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.FHRPGroup
|
||||
|
|
|
@ -844,6 +844,25 @@ class IPAddress(PrimaryModel):
|
|||
'address': _("Cannot create IP address with /0 mask.")
|
||||
})
|
||||
|
||||
# Do not allow assigning a network ID or broadcast address to an interface.
|
||||
if self.assigned_object:
|
||||
if self.address.ip == self.address.network:
|
||||
msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(
|
||||
ip=self.address.ip
|
||||
)
|
||||
if self.address.version == 4 and self.address.prefixlen not in (31, 32):
|
||||
raise ValidationError(msg)
|
||||
if self.address.version == 6 and self.address.prefixlen not in (127, 128):
|
||||
raise ValidationError(msg)
|
||||
if (
|
||||
self.address.version == 4 and self.address.ip == self.address.broadcast and
|
||||
self.address.prefixlen not in (31, 32)
|
||||
):
|
||||
msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
|
||||
ip=self.address.ip
|
||||
)
|
||||
raise ValidationError(msg)
|
||||
|
||||
# Enforce unique IP space (if applicable)
|
||||
if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
|
||||
duplicate_ips = self.get_duplicates()
|
||||
|
|
|
@ -1,14 +1,19 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import BaseValidator, RegexValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def prefix_validator(prefix):
|
||||
if prefix.ip != prefix.cidr.ip:
|
||||
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
|
||||
raise ValidationError(
|
||||
_("{prefix} is not a valid prefix. Did you mean {suggested}?").format(
|
||||
prefix=prefix, suggested=prefix.cidr
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class MaxPrefixLengthValidator(BaseValidator):
|
||||
message = 'The prefix length must be less than or equal to %(limit_value)s.'
|
||||
message = _('The prefix length must be less than or equal to %(limit_value)s.')
|
||||
code = 'max_prefix_length'
|
||||
|
||||
def compare(self, a, b):
|
||||
|
@ -16,7 +21,7 @@ class MaxPrefixLengthValidator(BaseValidator):
|
|||
|
||||
|
||||
class MinPrefixLengthValidator(BaseValidator):
|
||||
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
|
||||
message = _('The prefix length must be greater than or equal to %(limit_value)s.')
|
||||
code = 'min_prefix_length'
|
||||
|
||||
def compare(self, a, b):
|
||||
|
@ -25,6 +30,6 @@ class MinPrefixLengthValidator(BaseValidator):
|
|||
|
||||
DNSValidator = RegexValidator(
|
||||
regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$',
|
||||
message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names',
|
||||
message=_('Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names'),
|
||||
code='invalid'
|
||||
)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from netaddr import IPNetwork
|
||||
|
@ -58,11 +59,11 @@ class ChoiceField(serializers.Field):
|
|||
if data == '':
|
||||
if self.allow_blank:
|
||||
return data
|
||||
raise ValidationError("This field may not be blank.")
|
||||
raise ValidationError(_("This field may not be blank."))
|
||||
|
||||
# Provide an explicit error message if the request is trying to write a dict or list
|
||||
if isinstance(data, (dict, list)):
|
||||
raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
|
||||
raise ValidationError(_('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.'))
|
||||
|
||||
# Check for string representations of boolean/integer values
|
||||
if hasattr(data, 'lower'):
|
||||
|
@ -82,7 +83,7 @@ class ChoiceField(serializers.Field):
|
|||
except TypeError: # Input is an unhashable type
|
||||
pass
|
||||
|
||||
raise ValidationError(f"{data} is not a valid choice.")
|
||||
raise ValidationError(_("{value} is not a valid choice.").format(value=data))
|
||||
|
||||
@property
|
||||
def choices(self):
|
||||
|
@ -95,8 +96,8 @@ class ContentTypeField(RelatedField):
|
|||
Represent a ContentType as '<app_label>.<model>'
|
||||
"""
|
||||
default_error_messages = {
|
||||
"does_not_exist": "Invalid content type: {content_type}",
|
||||
"invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.",
|
||||
"does_not_exist": _("Invalid content type: {content_type}"),
|
||||
"invalid": _("Invalid value. Specify a content type as '<app_label>.<model_name>'."),
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
@ -30,9 +31,12 @@ class WritableNestedSerializer(BaseModelSerializer):
|
|||
try:
|
||||
return queryset.get(**params)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(f"Related object not found using the provided attributes: {params}")
|
||||
raise ValidationError(
|
||||
_("Related object not found using the provided attributes: {params}").format(params=params))
|
||||
except MultipleObjectsReturned:
|
||||
raise ValidationError(f"Multiple objects match the provided attributes: {params}")
|
||||
raise ValidationError(
|
||||
_("Multiple objects match the provided attributes: {params}").format(params=params)
|
||||
)
|
||||
except FieldError as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
|
@ -42,15 +46,17 @@ class WritableNestedSerializer(BaseModelSerializer):
|
|||
pk = int(data)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(
|
||||
f"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
|
||||
f"unrecognized value: {data}"
|
||||
_(
|
||||
"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
|
||||
"unrecognized value: {value}"
|
||||
).format(value=data)
|
||||
)
|
||||
|
||||
# Look up object by PK
|
||||
try:
|
||||
return self.Meta.model.objects.get(pk=pk)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(f"Related object not found using the provided numeric ID: {pk}")
|
||||
raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk))
|
||||
|
||||
|
||||
# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers
|
||||
|
|
|
@ -7,6 +7,7 @@ from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _Rem
|
|||
from django.contrib.auth.models import Group, AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from users.constants import CONSTRAINT_TOKEN_USER
|
||||
from users.models import ObjectPermission
|
||||
|
@ -42,6 +43,7 @@ AUTH_BACKEND_ATTRS = {
|
|||
'hubspot': ('HubSpot', 'hubspot'),
|
||||
'keycloak': ('Keycloak', None),
|
||||
'microsoft-graph': ('Microsoft Graph', 'microsoft'),
|
||||
'oidc': ('OpenID Connect', None),
|
||||
'okta': ('Okta', None),
|
||||
'okta-openidconnect': ('Okta (OIDC)', None),
|
||||
'salesforce-oauth2': ('Salesforce', 'salesforce'),
|
||||
|
@ -132,7 +134,9 @@ class ObjectPermissionMixin:
|
|||
# Sanity check: Ensure that the requested permission applies to the specified object
|
||||
model = obj._meta.concrete_model
|
||||
if model._meta.label_lower != '.'.join((app_label, model_name)):
|
||||
raise ValueError(f"Invalid permission {perm} for model {model}")
|
||||
raise ValueError(_("Invalid permission {permission} for model {model}").format(
|
||||
permission=perm, model=model
|
||||
))
|
||||
|
||||
# Compile a QuerySet filter that matches all instances of the specified model
|
||||
tokens = {
|
||||
|
|
|
@ -4,6 +4,7 @@ import threading
|
|||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.utils import DatabaseError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .parameters import PARAMS
|
||||
|
||||
|
@ -63,7 +64,7 @@ class Config:
|
|||
if item in self.defaults:
|
||||
return self.defaults[item]
|
||||
|
||||
raise AttributeError(f"Invalid configuration parameter: {item}")
|
||||
raise AttributeError(_("Invalid configuration parameter: {item}").format(item=item))
|
||||
|
||||
def _populate_from_cache(self):
|
||||
"""Populate config data from Redis cache"""
|
||||
|
|
|
@ -35,7 +35,9 @@ class CustomFieldsMixin:
|
|||
Return the ContentType of the form's model.
|
||||
"""
|
||||
if not getattr(self, 'model', None):
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
|
||||
raise NotImplementedError(_("{class_name} must specify a model class.").format(
|
||||
class_name=self.__class__.__name__
|
||||
))
|
||||
return ContentType.objects.get_for_model(self.model)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
|
|
|
@ -275,16 +275,20 @@ class CustomFieldsMixin(models.Model):
|
|||
# Validate all field values
|
||||
for field_name, value in self.custom_field_data.items():
|
||||
if field_name not in custom_fields:
|
||||
raise ValidationError(f"Unknown field name '{field_name}' in custom field data.")
|
||||
raise ValidationError(_("Unknown field name '{name}' in custom field data.").format(
|
||||
name=field_name
|
||||
))
|
||||
try:
|
||||
custom_fields[field_name].validate(value)
|
||||
except ValidationError as e:
|
||||
raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}")
|
||||
raise ValidationError(_("Invalid value for custom field '{name}': {error}").format(
|
||||
name=field_name, error=e.message
|
||||
))
|
||||
|
||||
# Check for missing required values
|
||||
for cf in custom_fields.values():
|
||||
if cf.required and cf.name not in self.custom_field_data:
|
||||
raise ValidationError(f"Missing required custom field '{cf.name}'.")
|
||||
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
|
||||
|
||||
|
||||
class CustomLinksMixin(models.Model):
|
||||
|
@ -547,7 +551,9 @@ class SyncedDataMixin(models.Model):
|
|||
Inheriting models must override this method with specific logic to copy data from the assigned DataFile
|
||||
to the local instance. This method should *NOT* call save() on the instance.
|
||||
"""
|
||||
raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.")
|
||||
raise NotImplementedError(_("{class_name} must implement a sync_data() method.").format(
|
||||
class_name=self.__class__
|
||||
))
|
||||
|
||||
|
||||
#
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from netbox.navigation import MenuGroup
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'PluginMenu',
|
||||
|
@ -42,11 +43,11 @@ class PluginMenuItem:
|
|||
self.staff_only = staff_only
|
||||
if permissions is not None:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError("Permissions must be passed as a tuple or list.")
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
self.permissions = permissions
|
||||
if buttons is not None:
|
||||
if type(buttons) not in (list, tuple):
|
||||
raise TypeError("Buttons must be passed as a tuple or list.")
|
||||
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
||||
self.buttons = buttons
|
||||
|
||||
|
||||
|
@ -64,9 +65,9 @@ class PluginMenuButton:
|
|||
self.icon_class = icon_class
|
||||
if permissions is not None:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError("Permissions must be passed as a tuple or list.")
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
self.permissions = permissions
|
||||
if color is not None:
|
||||
if color not in ButtonColorChoices.values():
|
||||
raise ValueError("Button color must be a choice within ButtonColorChoices.")
|
||||
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
|
||||
self.color = color
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import inspect
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from netbox.registry import registry
|
||||
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
|
||||
from .templates import PluginTemplateExtension
|
||||
|
@ -20,18 +21,32 @@ def register_template_extensions(class_list):
|
|||
# Validation
|
||||
for template_extension in class_list:
|
||||
if not inspect.isclass(template_extension):
|
||||
raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
|
||||
raise TypeError(
|
||||
_("PluginTemplateExtension class {template_extension} was passed as an instance!").format(
|
||||
template_extension=template_extension
|
||||
)
|
||||
)
|
||||
if not issubclass(template_extension, PluginTemplateExtension):
|
||||
raise TypeError(f"{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!")
|
||||
raise TypeError(
|
||||
_("{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!").format(
|
||||
template_extension=template_extension
|
||||
)
|
||||
)
|
||||
if template_extension.model is None:
|
||||
raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
|
||||
raise TypeError(
|
||||
_("PluginTemplateExtension class {template_extension} does not define a valid model!").format(
|
||||
template_extension=template_extension
|
||||
)
|
||||
)
|
||||
|
||||
registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
|
||||
|
||||
|
||||
def register_menu(menu):
|
||||
if not isinstance(menu, PluginMenu):
|
||||
raise TypeError(f"{menu} must be an instance of netbox.plugins.PluginMenu")
|
||||
raise TypeError(_("{item} must be an instance of netbox.plugins.PluginMenuItem").format(
|
||||
item=menu_link
|
||||
))
|
||||
registry['plugins']['menus'].append(menu)
|
||||
|
||||
|
||||
|
@ -42,10 +57,14 @@ def register_menu_items(section_name, class_list):
|
|||
# Validation
|
||||
for menu_link in class_list:
|
||||
if not isinstance(menu_link, PluginMenuItem):
|
||||
raise TypeError(f"{menu_link} must be an instance of netbox.plugins.PluginMenuItem")
|
||||
raise TypeError(_("{menu_link} must be an instance of netbox.plugins.PluginMenuItem").format(
|
||||
menu_link=menu_link
|
||||
))
|
||||
for button in menu_link.buttons:
|
||||
if not isinstance(button, PluginMenuButton):
|
||||
raise TypeError(f"{button} must be an instance of netbox.plugins.PluginMenuButton")
|
||||
raise TypeError(_("{button} must be an instance of netbox.plugins.PluginMenuButton").format(
|
||||
button=button
|
||||
))
|
||||
|
||||
registry['plugins']['menu_items'][section_name] = class_list
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.template.loader import get_template
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'PluginTemplateExtension',
|
||||
|
@ -31,7 +32,7 @@ class PluginTemplateExtension:
|
|||
if extra_context is None:
|
||||
extra_context = {}
|
||||
elif not isinstance(extra_context, dict):
|
||||
raise TypeError("extra_context must be a dictionary")
|
||||
raise TypeError(_("extra_context must be a dictionary"))
|
||||
|
||||
return get_template(template_name).render({**self.context, **extra_context})
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import collections
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
class Registry(dict):
|
||||
|
@ -10,13 +11,13 @@ class Registry(dict):
|
|||
try:
|
||||
return super().__getitem__(key)
|
||||
except KeyError:
|
||||
raise KeyError(f"Invalid store: {key}")
|
||||
raise KeyError(_("Invalid store: {key}").format(key=key))
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
raise TypeError("Cannot add stores to registry after initialization")
|
||||
raise TypeError(_("Cannot add stores to registry after initialization"))
|
||||
|
||||
def __delitem__(self, key):
|
||||
raise TypeError("Cannot delete stores from registry")
|
||||
raise TypeError(_("Cannot delete stores from registry"))
|
||||
|
||||
|
||||
# Initialize the global registry
|
||||
|
|
|
@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
|
|||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.7.3-dev'
|
||||
VERSION = '3.7.4-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
@ -571,7 +571,6 @@ SOCIAL_AUTH_PIPELINE = (
|
|||
'social_core.pipeline.social_auth.social_uid',
|
||||
'social_core.pipeline.social_auth.social_user',
|
||||
'social_core.pipeline.user.get_username',
|
||||
'social_core.pipeline.social_auth.associate_by_email',
|
||||
'social_core.pipeline.user.create_user',
|
||||
'social_core.pipeline.social_auth.associate_user',
|
||||
'netbox.authentication.user_default_groups_handler',
|
||||
|
|
|
@ -14,6 +14,7 @@ from django.http import HttpResponse
|
|||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django_tables2.export import TableExport
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
|
@ -320,7 +321,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||
if type(field.widget) is not HiddenInput
|
||||
}
|
||||
|
||||
def _save_object(self, model_form, request):
|
||||
def _save_object(self, import_form, model_form, request):
|
||||
|
||||
# Save the primary object
|
||||
obj = self.save_object(model_form, request)
|
||||
|
@ -345,11 +346,14 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||
related_obj = f.save()
|
||||
related_obj_pks.append(related_obj.pk)
|
||||
else:
|
||||
# Replicate errors on the related object form to the primary form for display
|
||||
# Replicate errors on the related object form to the import form for display and abort
|
||||
for subfield_name, errors in f.errors.items():
|
||||
for err in errors:
|
||||
err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
|
||||
model_form.add_error(None, err_msg)
|
||||
if subfield_name == '__all__':
|
||||
err_msg = f"{field_name}[{i}]: {err}"
|
||||
else:
|
||||
err_msg = f"{field_name}[{i}] {subfield_name}: {err}"
|
||||
import_form.add_error(None, err_msg)
|
||||
raise AbortTransaction()
|
||||
|
||||
# Enforce object-level permissions on related objects
|
||||
|
@ -390,7 +394,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||
try:
|
||||
instance = prefetched_objects[object_id]
|
||||
except KeyError:
|
||||
form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
|
||||
form.add_error('data', _("Row {i}: Object with ID {id} does not exist").format(i=i, id=object_id))
|
||||
raise ValidationError('')
|
||||
|
||||
# Take a snapshot for change logging
|
||||
|
@ -416,7 +420,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||
restrict_form_fields(model_form, request.user)
|
||||
|
||||
if model_form.is_valid():
|
||||
obj = self._save_object(model_form, request)
|
||||
obj = self._save_object(form, model_form, request)
|
||||
saved_objects.append(obj)
|
||||
else:
|
||||
# Replicate model form errors for display
|
||||
|
|
|
@ -11,6 +11,7 @@ from django.shortcuts import redirect, render
|
|||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.signals import clear_events
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
|
@ -101,7 +102,9 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
|||
request: The current request
|
||||
parent: The parent object
|
||||
"""
|
||||
raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
|
||||
raise NotImplementedError(_('{class_name} must implement get_children()').format(
|
||||
class_name=self.__class__.__name__
|
||||
))
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
"""
|
||||
|
|
|
@ -34,6 +34,10 @@
|
|||
<th scope="row">{% trans "Account Created" %}</th>
|
||||
<td>{{ request.user.date_joined|annotated_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Last Login" %}</th>
|
||||
<td>{{ request.user.last_login|annotated_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Superuser" %}</th>
|
||||
<td>{% checkmark request.user.is_superuser %}</td>
|
||||
|
|
|
@ -63,7 +63,7 @@
|
|||
<td>
|
||||
{{ object.scheduled|annotated_date|placeholder }}
|
||||
{% if object.interval %}
|
||||
({% blocktrans with interval=object.interval %}every {{ interval }} seconds{% endblocktrans %})
|
||||
({% blocktrans with interval=object.interval %}every {{ interval }} minutes{% endblocktrans %})
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
<td>{{ object.u_height|floatformat }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans "Exclude From Utilization" %})</td>
|
||||
<td>{% trans "Exclude From Utilization" %}</td>
|
||||
<td>{% checkmark object.exclude_from_utilization %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
|
@ -30,6 +30,10 @@
|
|||
<th scope="row">{% trans "Account Created" %}</th>
|
||||
<td>{{ object.date_joined|annotated_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Last Login" %}</th>
|
||||
<td>{{ object.last_login|annotated_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Active" %}</th>
|
||||
<td>{% checkmark object.is_active %}</td>
|
||||
|
|
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
@ -35,7 +35,7 @@ class UserSerializer(ValidatedModelSerializer):
|
|||
model = get_user_model()
|
||||
fields = (
|
||||
'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active',
|
||||
'date_joined', 'groups',
|
||||
'date_joined', 'last_login', 'groups',
|
||||
)
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': True}
|
||||
|
|
|
@ -380,6 +380,12 @@ class ObjectPermissionForm(BootstrapMixin, forms.ModelForm):
|
|||
constraints = [constraints]
|
||||
for ct in object_types:
|
||||
model = ct.model_class()
|
||||
|
||||
if model._meta.model_name in ['script', 'report']:
|
||||
raise forms.ValidationError({
|
||||
'constraints': _('Constraints are not supported for this object type.')
|
||||
})
|
||||
|
||||
try:
|
||||
tokens = {
|
||||
CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID
|
||||
|
|
|
@ -93,7 +93,7 @@ class RestrictedGenericForeignKey(GenericForeignKey):
|
|||
if type(queryset) is dict:
|
||||
restrict_params = queryset
|
||||
elif queryset is not None:
|
||||
raise ValueError("Custom queryset can't be used for this lookup.")
|
||||
raise ValueError(_("Custom queryset can't be used for this lookup."))
|
||||
|
||||
# For efficiency, group the instances by content type and then do one
|
||||
# query per model
|
||||
|
|
|
@ -49,7 +49,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
|
|||
|
||||
# Determine whether we're reading from form data or an uploaded file
|
||||
if self.cleaned_data['data'] and import_method != ImportMethodChoices.DIRECT:
|
||||
raise forms.ValidationError("Form data must be empty when uploading/selecting a file.")
|
||||
raise forms.ValidationError(_("Form data must be empty when uploading/selecting a file."))
|
||||
if import_method == ImportMethodChoices.UPLOAD:
|
||||
self.upload_file = 'upload_file'
|
||||
file = self.files.get('upload_file')
|
||||
|
@ -78,7 +78,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
|
|||
elif format == ImportFormatChoices.YAML:
|
||||
self.cleaned_data['data'] = self._clean_yaml(data)
|
||||
else:
|
||||
raise forms.ValidationError(f"Unknown data format: {format}")
|
||||
raise forms.ValidationError(_("Unknown data format: {format}").format(format=format))
|
||||
|
||||
def _detect_format(self, data):
|
||||
"""
|
||||
|
|
|
@ -93,6 +93,8 @@ class JSONField(_JSONField):
|
|||
"""
|
||||
Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
|
||||
"""
|
||||
empty_values = [None, '', ()]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.help_text:
|
||||
|
|
|
@ -2,6 +2,7 @@ import re
|
|||
|
||||
from django import forms
|
||||
from django.forms.models import fields_for_model
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from utilities.choices import unpack_grouped_choices
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
@ -38,7 +39,7 @@ def parse_numeric_range(string, base=10):
|
|||
try:
|
||||
begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
|
||||
except ValueError:
|
||||
raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
|
||||
raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
|
||||
values.extend(range(begin, end))
|
||||
return sorted(set(values))
|
||||
|
||||
|
@ -50,36 +51,43 @@ def parse_alphanumeric_range(string):
|
|||
'0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
|
||||
"""
|
||||
values = []
|
||||
for dash_range in string.split(','):
|
||||
for value in string.split(','):
|
||||
if '-' not in value:
|
||||
# Item is not a range
|
||||
values.append(value)
|
||||
continue
|
||||
|
||||
# Find the range's beginning & end values
|
||||
try:
|
||||
begin, end = dash_range.split('-')
|
||||
begin, end = value.split('-')
|
||||
vals = begin + end
|
||||
# Break out of loop if there's an invalid pattern to return an error
|
||||
if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
|
||||
return []
|
||||
except ValueError:
|
||||
begin, end = dash_range, dash_range
|
||||
raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value))
|
||||
|
||||
# Numeric range
|
||||
if begin.isdigit() and end.isdigit():
|
||||
if int(begin) >= int(end):
|
||||
raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
|
||||
|
||||
raise forms.ValidationError(
|
||||
_('Invalid range: Ending value ({end}) must be greater than beginning value ({begin}).').format(
|
||||
begin=begin, end=end
|
||||
)
|
||||
)
|
||||
for n in list(range(int(begin), int(end) + 1)):
|
||||
values.append(n)
|
||||
|
||||
# Alphanumeric range
|
||||
else:
|
||||
# Value-based
|
||||
if begin == end:
|
||||
values.append(begin)
|
||||
# Range-based
|
||||
else:
|
||||
# Not a valid range (more than a single character)
|
||||
if not len(begin) == len(end) == 1:
|
||||
raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
|
||||
# Not a valid range (more than a single character)
|
||||
if not len(begin) == len(end) == 1:
|
||||
raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value))
|
||||
if ord(begin) >= ord(end):
|
||||
raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value))
|
||||
for n in list(range(ord(begin), ord(end) + 1)):
|
||||
values.append(chr(n))
|
||||
|
||||
if ord(begin) >= ord(end):
|
||||
raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
|
||||
|
||||
for n in list(range(ord(begin), ord(end) + 1)):
|
||||
values.append(chr(n))
|
||||
return values
|
||||
|
||||
|
||||
|
@ -221,18 +229,24 @@ def parse_csv(reader):
|
|||
if '.' in header:
|
||||
field, to_field = header.split('.', 1)
|
||||
if field in headers:
|
||||
raise forms.ValidationError(f'Duplicate or conflicting column header for "{field}"')
|
||||
raise forms.ValidationError(_('Duplicate or conflicting column header for "{field}"').format(
|
||||
field=field
|
||||
))
|
||||
headers[field] = to_field
|
||||
else:
|
||||
if header in headers:
|
||||
raise forms.ValidationError(f'Duplicate or conflicting column header for "{header}"')
|
||||
raise forms.ValidationError(_('Duplicate or conflicting column header for "{header}"').format(
|
||||
header=header
|
||||
))
|
||||
headers[header] = None
|
||||
|
||||
# Parse CSV rows into a list of dictionaries mapped from the column headers.
|
||||
for i, row in enumerate(reader, start=1):
|
||||
if len(row) != len(headers):
|
||||
raise forms.ValidationError(
|
||||
f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
|
||||
_("Row {row}: Expected {count_expected} columns but found {count_found}").format(
|
||||
row=i, count_expected=len(headers), count_found=len(row)
|
||||
)
|
||||
)
|
||||
row = [col.strip() for col in row]
|
||||
record = dict(zip(headers.keys(), row))
|
||||
|
@ -253,14 +267,18 @@ def validate_csv(headers, fields, required_fields):
|
|||
is_update = True
|
||||
continue
|
||||
if field not in fields:
|
||||
raise forms.ValidationError(f'Unexpected column header "{field}" found.')
|
||||
raise forms.ValidationError(_('Unexpected column header "{field}" found.').format(field=field))
|
||||
if to_field and not hasattr(fields[field], 'to_field_name'):
|
||||
raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots')
|
||||
raise forms.ValidationError(_('Column "{field}" is not a related object; cannot use dots').format(
|
||||
field=field
|
||||
))
|
||||
if to_field and not hasattr(fields[field].queryset.model, to_field):
|
||||
raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}')
|
||||
raise forms.ValidationError(_('Invalid related object attribute for column "{field}": {to_field}').format(
|
||||
field=field, to_field=to_field
|
||||
))
|
||||
|
||||
# Validate required fields (if not an update)
|
||||
if not is_update:
|
||||
for f in required_fields:
|
||||
if f not in headers:
|
||||
raise forms.ValidationError(f'Required column header "{f}" not found.')
|
||||
raise forms.ValidationError(_('Required column header "{header}" not found.').format(header=f))
|
||||
|
|
|
@ -3,6 +3,7 @@ from typing import Dict, List, Tuple
|
|||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
__all__ = (
|
||||
'APISelect',
|
||||
|
@ -119,7 +120,11 @@ class APISelect(forms.Select):
|
|||
update = [{'fieldName': f, 'queryParam': q} for (f, q) in self.dynamic_params.items()]
|
||||
self._serialize_params(key, update)
|
||||
except IndexError as error:
|
||||
raise RuntimeError(f"Missing required value for dynamic query param: '{self.dynamic_params}'") from error
|
||||
raise RuntimeError(
|
||||
_("Missing required value for dynamic query param: '{dynamic_params}'").format(
|
||||
dynamic_params=self.dynamic_params
|
||||
)
|
||||
) from error
|
||||
|
||||
def _add_static_params(self):
|
||||
"""
|
||||
|
@ -132,7 +137,11 @@ class APISelect(forms.Select):
|
|||
update = [{'queryParam': k, 'queryValue': v} for (k, v) in self.static_params.items()]
|
||||
self._serialize_params(key, update)
|
||||
except IndexError as error:
|
||||
raise RuntimeError(f"Missing required value for static query param: '{self.static_params}'") from error
|
||||
raise RuntimeError(
|
||||
_("Missing required value for static query param: '{static_params}'").format(
|
||||
static_params=self.static_params
|
||||
)
|
||||
) from error
|
||||
|
||||
def add_query_params(self, query_params):
|
||||
"""
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
__all__ = (
|
||||
'get_permission_for_model',
|
||||
|
@ -36,7 +37,7 @@ def resolve_permission(name):
|
|||
action, model_name = codename.rsplit('_', 1)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid permission name: {name}. Must be in the format <app_label>.<action>_<model>"
|
||||
_("Invalid permission name: {name}. Must be in the format <app_label>.<action>_<model>").format(name=name)
|
||||
)
|
||||
|
||||
return app_label, action, model_name
|
||||
|
@ -53,7 +54,7 @@ def resolve_permission_ct(name):
|
|||
try:
|
||||
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
|
||||
except ContentType.DoesNotExist:
|
||||
raise ValueError(f"Unknown app_label/model_name for {name}")
|
||||
raise ValueError(_("Unknown app_label/model_name for {name}").format(name=name))
|
||||
|
||||
return content_type, action
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
from netaddr import AddrFormatError, IPAddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
@ -29,7 +30,7 @@ def get_client_ip(request, additional_headers=()):
|
|||
return IPAddress(ip)
|
||||
except AddrFormatError:
|
||||
# We did our best
|
||||
raise ValueError(f"Invalid IP address set for {header}: {ip}")
|
||||
raise ValueError(_("Invalid IP address set for {header}: {ip}").format(header=header, ip=ip))
|
||||
|
||||
# Could not determine the client IP address from request headers
|
||||
return None
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
from netbox.registry import registry
|
||||
|
||||
__all__ = (
|
||||
|
@ -43,5 +44,7 @@ def register_table_column(column, name, *tables):
|
|||
for table in tables:
|
||||
reg = registry['tables'][table]
|
||||
if name in reg:
|
||||
raise ValueError(f"A column named {name} is already defined for table {table.__name__}")
|
||||
raise ValueError(_("A column named {name} is already defined for table {table_name}").format(
|
||||
name=name, table_name=table.__name__
|
||||
))
|
||||
reg[name] = column
|
||||
|
|
|
@ -6,6 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
|||
from django.db.models import ForeignKey
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import ObjectChangeActionChoices
|
||||
from extras.models import ObjectChange
|
||||
|
@ -621,7 +622,7 @@ class ViewTestCases:
|
|||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_bulk_update_objects_with_permission(self):
|
||||
if not hasattr(self, 'csv_update_data'):
|
||||
raise NotImplementedError("The test must define csv_update_data.")
|
||||
raise NotImplementedError(_("The test must define csv_update_data."))
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
array, csv_data = self._get_update_csv_data()
|
||||
|
|
|
@ -191,7 +191,16 @@ class ExpandAlphanumeric(TestCase):
|
|||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_set(self):
|
||||
def test_set_numeric(self):
|
||||
input = 'r[1,2]a'
|
||||
output = sorted([
|
||||
'r1a',
|
||||
'r2a',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_set_alpha(self):
|
||||
input = '[r,t]1a'
|
||||
output = sorted([
|
||||
'r1a',
|
||||
|
|
|
@ -15,6 +15,7 @@ from django.utils import timezone
|
|||
from django.utils.datastructures import MultiValueDict
|
||||
from django.utils.html import escape
|
||||
from django.utils.timezone import localtime
|
||||
from django.utils.translation import gettext as _
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from mptt.models import MPTTModel
|
||||
|
||||
|
@ -306,13 +307,17 @@ def to_meters(length, unit):
|
|||
"""
|
||||
try:
|
||||
if length < 0:
|
||||
raise ValueError("Length must be a positive number")
|
||||
raise ValueError(_("Length must be a positive number"))
|
||||
except TypeError:
|
||||
raise TypeError(f"Invalid value '{length}' for length (must be a number)")
|
||||
raise TypeError(_("Invalid value '{length}' for length (must be a number)").format(length=length))
|
||||
|
||||
valid_units = CableLengthUnitChoices.values()
|
||||
if unit not in valid_units:
|
||||
raise ValueError(f"Unknown unit {unit}. Must be one of the following: {', '.join(valid_units)}")
|
||||
raise ValueError(
|
||||
_("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
|
||||
unit=unit, valid_units=', '.join(valid_units)
|
||||
)
|
||||
)
|
||||
|
||||
if unit == CableLengthUnitChoices.UNIT_KILOMETER:
|
||||
return length * 1000
|
||||
|
@ -326,7 +331,7 @@ def to_meters(length, unit):
|
|||
return length * Decimal(0.3048)
|
||||
if unit == CableLengthUnitChoices.UNIT_INCH:
|
||||
return length * Decimal(0.0254)
|
||||
raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.")
|
||||
raise ValueError(_("Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.").format(unit=unit))
|
||||
|
||||
|
||||
def to_grams(weight, unit):
|
||||
|
@ -335,13 +340,17 @@ def to_grams(weight, unit):
|
|||
"""
|
||||
try:
|
||||
if weight < 0:
|
||||
raise ValueError("Weight must be a positive number")
|
||||
raise ValueError(_("Weight must be a positive number"))
|
||||
except TypeError:
|
||||
raise TypeError(f"Invalid value '{weight}' for weight (must be a number)")
|
||||
raise TypeError(_("Invalid value '{weight}' for weight (must be a number)").format(weight=weight))
|
||||
|
||||
valid_units = WeightUnitChoices.values()
|
||||
if unit not in valid_units:
|
||||
raise ValueError(f"Unknown unit {unit}. Must be one of the following: {', '.join(valid_units)}")
|
||||
raise ValueError(
|
||||
_("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
|
||||
unit=unit, valid_units=', '.join(valid_units)
|
||||
)
|
||||
)
|
||||
|
||||
if unit == WeightUnitChoices.UNIT_KILOGRAM:
|
||||
return weight * 1000
|
||||
|
@ -351,7 +360,7 @@ def to_grams(weight, unit):
|
|||
return weight * Decimal(453.592)
|
||||
if unit == WeightUnitChoices.UNIT_OUNCE:
|
||||
return weight * Decimal(28.3495)
|
||||
raise ValueError(f"Unknown unit {unit}. Must be 'kg', 'g', 'lb', 'oz'.")
|
||||
raise ValueError(_("Unknown unit {unit}. Must be 'kg', 'g', 'lb', 'oz'.").format(unit=unit))
|
||||
|
||||
|
||||
def render_jinja2(template_code, context):
|
||||
|
|
|
@ -2,6 +2,7 @@ import re
|
|||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import BaseValidator, RegexValidator, URLValidator, _lazy_re_compile
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.config import get_config
|
||||
|
||||
|
@ -61,4 +62,4 @@ def validate_regex(value):
|
|||
try:
|
||||
re.compile(value)
|
||||
except re.error:
|
||||
raise ValidationError(f"{value} is not a valid regular expression.")
|
||||
raise ValidationError(_("{value} is not a valid regular expression.").format(value=value))
|
||||
|
|
|
@ -2,6 +2,7 @@ from django.contrib.auth.mixins import AccessMixin
|
|||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.urls import reverse
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from .permissions import resolve_permission
|
||||
|
@ -34,7 +35,9 @@ class ContentTypePermissionRequiredMixin(AccessMixin):
|
|||
"""
|
||||
Return the specific permission necessary to perform the requested action on an object.
|
||||
"""
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()")
|
||||
raise NotImplementedError(_("{self.__class__.__name__} must implement get_required_permission()").format(
|
||||
class_name=self.__class__.__name__
|
||||
))
|
||||
|
||||
def has_permission(self):
|
||||
user = self.request.user
|
||||
|
@ -68,7 +71,9 @@ class ObjectPermissionRequiredMixin(AccessMixin):
|
|||
"""
|
||||
Return the specific permission necessary to perform the requested action on an object.
|
||||
"""
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()")
|
||||
raise NotImplementedError(_("{class_name} must implement get_required_permission()").format(
|
||||
class_name=self.__class__.__name__
|
||||
))
|
||||
|
||||
def has_permission(self):
|
||||
user = self.request.user
|
||||
|
@ -89,8 +94,10 @@ class ObjectPermissionRequiredMixin(AccessMixin):
|
|||
|
||||
if not hasattr(self, 'queryset'):
|
||||
raise ImproperlyConfigured(
|
||||
'{} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views which define '
|
||||
'a base queryset'.format(self.__class__.__name__)
|
||||
_(
|
||||
'{class_name} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views '
|
||||
'which define a base queryset'
|
||||
).format(class_name=self.__class__.__name__)
|
||||
)
|
||||
|
||||
if not self.has_permission():
|
||||
|
|
|
@ -172,6 +172,6 @@ class VirtualDiskSerializer(NetBoxModelSerializer):
|
|||
class Meta:
|
||||
model = VirtualDisk
|
||||
fields = [
|
||||
'id', 'url', 'virtual_machine', 'name', 'description', 'size', 'tags', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
'id', 'url', 'display', 'virtual_machine', 'name', 'description', 'size', 'tags', 'custom_fields',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
|
|
@ -33,6 +33,15 @@ VMINTERFACE_BUTTONS = """
|
|||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if perms.vpn.add_tunnel and not record.tunnel_termination %}
|
||||
<a href="{% url 'vpn:tunnel_add' %}?termination1_type=virtualization.virtualmachine&termination1_parent={{ record.virtual_machine.pk }}&termination1_termination={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" title="Create a tunnel" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %}
|
||||
<a href="{% url 'vpn:tunneltermination_delete' pk=record.tunnel_termination.pk %}?return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" title="Remove tunnel" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
|
@ -64,6 +73,10 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
|
|||
role = columns.ColoredLabelColumn(
|
||||
verbose_name=_('Role'),
|
||||
)
|
||||
platform = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Platform')
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
)
|
||||
|
@ -97,9 +110,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
|
|||
class Meta(NetBoxTable.Meta):
|
||||
model = VirtualMachine
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform',
|
||||
'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments',
|
||||
'config_template', 'contacts', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'vcpus',
|
||||
'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', 'config_template',
|
||||
'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
|
@ -124,7 +124,7 @@ class EncryptionAlgorithmChoices(ChoiceSet):
|
|||
(ENCRYPTION_AES256_CBC, '256-bit AES (CBC)'),
|
||||
(ENCRYPTION_AES256_GCM, '256-bit AES (GCM)'),
|
||||
(ENCRYPTION_3DES, '3DES'),
|
||||
(ENCRYPTION_3DES, 'DES'),
|
||||
(ENCRYPTION_DES, 'DES'),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from decimal import Decimal
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .choices import WirelessChannelChoices
|
||||
|
||||
|
@ -12,7 +13,7 @@ def get_channel_attr(channel, attr):
|
|||
Return the specified attribute of a given WirelessChannelChoices value.
|
||||
"""
|
||||
if channel not in WirelessChannelChoices.values():
|
||||
raise ValueError(f"Invalid channel value: {channel}")
|
||||
raise ValueError(_("Invalid channel value: {channel}").format(channel=channel))
|
||||
|
||||
channel_values = channel.split('-')
|
||||
attrs = {
|
||||
|
@ -22,6 +23,6 @@ def get_channel_attr(channel, attr):
|
|||
'width': Decimal(channel_values[3]),
|
||||
}
|
||||
if attr not in attrs:
|
||||
raise ValueError(f"Invalid channel attribute: {attr}")
|
||||
raise ValueError(_("Invalid channel attribute: {name}").format(name=attr))
|
||||
|
||||
return attrs[attr]
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
bleach==6.1.0
|
||||
Django==4.2.9
|
||||
Django==4.2.10
|
||||
django-cors-headers==4.3.1
|
||||
django-debug-toolbar==4.3.0
|
||||
django-filter==23.5
|
||||
|
@ -21,15 +21,15 @@ graphene-django==3.0.0
|
|||
gunicorn==21.2.0
|
||||
Jinja2==3.1.3
|
||||
Markdown==3.5.2
|
||||
mkdocs-material==9.5.7
|
||||
mkdocs-material==9.5.10
|
||||
mkdocstrings[python-legacy]==0.24.0
|
||||
netaddr==0.10.1
|
||||
netaddr==1.2.1
|
||||
Pillow==10.2.0
|
||||
psycopg[binary,pool]==3.1.18
|
||||
PyYAML==6.0.1
|
||||
requests==2.31.0
|
||||
social-auth-app-django==5.4.0
|
||||
social-auth-core[openidconnect]==4.5.2
|
||||
social-auth-core[openidconnect]==4.5.3
|
||||
svgwrite==1.4.3
|
||||
tablib==3.5.0
|
||||
tzdata==2023.4
|
||||
tzdata==2024.1
|
||||
|
|
Loading…
Reference in New Issue