diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 43ab47c9d..b0b8c02ad 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -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
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index a198fd731..bc99999c0 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -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
diff --git a/README.md b/README.md
index f166919c4..a81e41854 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
diff --git a/base_requirements.txt b/base_requirements.txt
index 82c2d1abc..de885eeb2 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -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
diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md
index fb789bd98..e7fe56a09 100644
--- a/docs/configuration/remote-authentication.md
+++ b/docs/configuration/remote-authentication.md
@@ -67,7 +67,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
Default: `|` (Pipe)
-The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+The Separator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md
index e2bc53cfc..c68bc21f1 100644
--- a/docs/customization/custom-scripts.md
+++ b/docs/customization/custom-scripts.md
@@ -390,7 +390,7 @@ class NewBranchScript(Script):
name=f'{site.slug}-switch{i}',
site=site,
status=DeviceStatusChoices.STATUS_PLANNED,
- role=switch_role
+ device_role=switch_role
)
switch.full_clean()
switch.save()
diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md
index c8d54db4f..8b14fe8d8 100644
--- a/docs/release-notes/version-3.7.md
+++ b/docs/release-notes/version-3.7.md
@@ -1,5 +1,40 @@
# NetBox v3.7
+## 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
+
+---
+
## v3.7.2 (2024-02-05)
### Enhancements
diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py
index 4dc775364..7b65d52ad 100644
--- a/netbox/circuits/models/circuits.py
+++ b/netbox/circuits/models/circuits.py
@@ -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)
diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py
index b7e537c23..8eecfa8b9 100644
--- a/netbox/core/api/schema.py
+++ b/netbox/core/api/schema.py
@@ -8,6 +8,7 @@ from drf_spectacular.plumbing import (
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
)
from drf_spectacular.types import OpenApiTypes
+from rest_framework import serializers
from rest_framework.relations import ManyRelatedField
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py
index 9ff0b4d63..15891a6f5 100644
--- a/netbox/core/data_backends.py
+++ b/netbox/core/data_backends.py
@@ -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
diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py
index 652728734..ae891dd59 100644
--- a/netbox/core/forms/model_forms.py
+++ b/netbox/core/forms/model_forms.py
@@ -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
diff --git a/netbox/core/models/config.py b/netbox/core/models/config.py
index 6c8e41477..b2381ae40 100644
--- a/netbox/core/models/config.py
+++ b/netbox/core/models/config.py
@@ -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)
diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py
index 6597a4b4d..4ceb22ba9 100644
--- a/netbox/core/models/data.py
+++ b/netbox/core/models/data.py
@@ -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:
diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py
index 7cc62a15a..2e3425129 100644
--- a/netbox/core/models/jobs.py
+++ b/netbox/core/models/jobs.py
@@ -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
diff --git a/netbox/core/views.py b/netbox/core/views.py
index 537c33d9d..6c87087f2 100644
--- a/netbox/core/views.py
+++ b/netbox/core/views.py
@@ -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
)
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 09933f2de..f8541f013 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -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)
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index cd5a297c9..bec52d97d 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -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):
"""
diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py
index db1a28d39..535756bac 100644
--- a/netbox/dcim/fields.py
+++ b/netbox/dcim/fields.py
@@ -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'
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 68edc93f6..d0163e988 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -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,
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index f30ff91fa..47974096f 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -159,6 +159,14 @@ class LocationImportForm(NetBoxModelImportForm):
model = Location
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
+ def __init__(self, data=None, *args, **kwargs):
+ super().__init__(data, *args, **kwargs)
+
+ if data:
+ # Limit location queryset by assigned site
+ params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+ self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
+
class RackRoleImportForm(NetBoxModelImportForm):
slug = SlugField()
@@ -870,7 +878,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
def clean_vdcs(self):
for vdc in self.cleaned_data['vdcs']:
if vdc.device != self.cleaned_data['device']:
- raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
+ raise forms.ValidationError(
+ _("VDC {vdc} is not assigned to device {device}").format(
+ vdc=vdc, device=self.cleaned_data['device']
+ )
+ )
return self.cleaned_data['vdcs']
@@ -996,7 +1008,7 @@ class DeviceBayImportForm(NetBoxModelImportForm):
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
).exclude(pk=device.pk)
else:
- self.fields['installed_device'].queryset = Interface.objects.none()
+ self.fields['installed_device'].queryset = Device.objects.none()
class InventoryItemImportForm(NetBoxModelImportForm):
@@ -1075,7 +1087,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
component = model.objects.get(device=device, name=component_name)
self.instance.component = component
except ObjectDoesNotExist:
- raise forms.ValidationError(f"Component not found: {device} - {component_name}")
+ raise forms.ValidationError(
+ _("Component not found: {device} - {component_name}").format(
+ device=device, component_name=component_name
+ )
+ )
#
@@ -1193,10 +1209,17 @@ class CableImportForm(NetBoxModelImportForm):
else:
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None and termination_object.cable != self.instance:
- raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
+ raise forms.ValidationError(
+ _("Side {side_upper}: {device} {termination_object} is already connected").format(
+ side_upper=side.upper(), device=device, termination_object=termination_object
+ )
+ )
except ObjectDoesNotExist:
- raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
-
+ raise forms.ValidationError(
+ _("{side_upper} side termination not found: {device} {name}").format(
+ side_upper=side.upper(), device=device, name=name
+ )
+ )
setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index da3a2bea4..6773bc55f 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -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'),
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index d1c80d0be..464735125 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -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):
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 88dddb312..5b2564b32 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
super().clean()
# Validate that the parent Device can have DeviceBays
- if not self.device.device_type.is_parent_device:
+ if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
device_type=self.device.device_type
))
# Cannot install a device into itself, obviously
- if self.device == self.installed_device:
+ if self.installed_device and getattr(self, 'device', None) == self.installed_device:
raise ValidationError(_("Cannot install a device into itself."))
# Check that the installed device is not already installed elsewhere
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 4b9689a22..f9e8ba213 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -875,7 +875,7 @@ class Device(
if self.position and self.device_type.u_height == 0:
raise ValidationError({
'position': _(
- "A U0 device type ({device_type}) cannot be assigned to a rack position."
+ "A 0U device type ({device_type}) cannot be assigned to a rack position."
).format(device_type=self.device_type)
})
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 4c863e12a..d4c9641b6 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -359,6 +359,11 @@ class CableTerminationTable(NetBoxTable):
verbose_name=_('Mark Connected'),
)
+ def value_link_peer(self, value):
+ return ', '.join([
+ f"{termination.parent_object} > {termination}" for termination in value
+ ])
+
class PathEndpointTable(CableTerminationTable):
connection = columns.TemplateColumn(
diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py
index 3f8b63688..de27d67ad 100644
--- a/netbox/dcim/tables/template_code.py
+++ b/netbox/dcim/tables/template_code.py
@@ -37,7 +37,7 @@ DEVICEBAY_STATUS = """
INTERFACE_IPADDRESSES = """
{% if value.count >= 3 %}
- {{ value.count }}
+ {{ value.count }}
{% else %}
{% for ip in value.all %}
{% if ip.status != 'active' %}
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index f36b11033..d02422c6f 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -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'
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index 89d15a0ef..b255c283e 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -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()
diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py
index ce3b388f4..6cd3a245e 100644
--- a/netbox/extras/api/customfields.py
+++ b/netbox/extras/api/customfields.py
@@ -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:
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index 714c92548..8f00e11d9 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -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
diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py
index db054149e..39005b752 100644
--- a/netbox/extras/conditions.py
+++ b/netbox/extras/conditions.py
@@ -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
diff --git a/netbox/extras/dashboard/utils.py b/netbox/extras/dashboard/utils.py
index 812b8db1b..20c508b2a 100644
--- a/netbox/extras/dashboard/utils.py
+++ b/netbox/extras/dashboard/utils.py
@@ -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):
diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py
index 8cfbb4c61..d4b777fb5 100644
--- a/netbox/extras/dashboard/widgets.py
+++ b/netbox/extras/dashboard/widgets.py
@@ -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):
diff --git a/netbox/extras/events.py b/netbox/extras/events.py
index c50f4488d..e74524ceb 100644
--- a/netbox/extras/events.py
+++ b/netbox/extras/events.py
@@ -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))
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index f8d3ffb7f..59ccc2bf9 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -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 = {
diff --git a/netbox/extras/management/commands/reindex.py b/netbox/extras/management/commands/reindex.py
index e072c220a..e20fad0ce 100644
--- a/netbox/extras/management/commands/reindex.py
+++ b/netbox/extras/management/commands/reindex.py
@@ -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)
diff --git a/netbox/extras/migrations/0107_cachedvalue_extras_cachedvalue_object.py b/netbox/extras/migrations/0107_cachedvalue_extras_cachedvalue_object.py
new file mode 100644
index 000000000..15ce375a2
--- /dev/null
+++ b/netbox/extras/migrations/0107_cachedvalue_extras_cachedvalue_object.py
@@ -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'),
+ ),
+ ]
diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py
index 9ba779642..3c2cebe8e 100644
--- a/netbox/extras/models/search.py
+++ b/netbox/extras/models/search.py
@@ -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}'
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index f28465547..7d86472c9 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -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
diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py
index 4c15e839a..f8dc204e7 100644
--- a/netbox/extras/signals.py
+++ b/netbox/extras/signals.py
@@ -1,8 +1,8 @@
-import importlib
import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
+from django.db.models.fields.reverse_related import ManyToManyRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
@@ -12,9 +12,10 @@ from core.signals import job_end, job_start
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.events import process_event_rules
from extras.models import EventRule
-from extras.validators import CustomValidator
+from extras.validators import run_validators
from netbox.config import get_config
from netbox.context import current_request, events_queue
+from netbox.models.features import ChangeLoggingMixin
from netbox.signals import post_clean
from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices
@@ -68,7 +69,7 @@ def handle_changed_object(sender, instance, **kwargs):
else:
return
- # Create/update an ObejctChange record for this change
+ # Create/update an ObjectChange record for this change
objectchange = instance.to_objectchange(action)
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded
# for this object by this request and update it
@@ -108,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
+ # Run any deletion protection rules for the object. Note that this must occur prior
+ # to queueing any events for the object being deleted, in case a validation error is
+ # raised, causing the deletion to fail.
+ model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
+ validators = get_config().PROTECTION_RULES.get(model_name, [])
+ try:
+ run_validators(instance, validators)
+ except ValidationError as e:
+ raise AbortRequest(
+ _("Deletion is prevented by a protection rule: {message}").format(message=e)
+ )
+
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
@@ -122,6 +135,25 @@ def handle_deleted_object(sender, instance, **kwargs):
objectchange.request_id = request.id
objectchange.save()
+ # Django does not automatically send an m2m_changed signal for the reverse direction of a
+ # many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
+ # trigger one manually. We do this by checking for any reverse M2M relationships on the
+ # instance being deleted, and explicitly call .remove() on the remote M2M field to delete
+ # the association. This triggers an m2m_changed signal with the `post_remove` action type
+ # for the forward direction of the relationship, ensuring that the change is recorded.
+ for relation in instance._meta.related_objects:
+ if type(relation) is not ManyToManyRel:
+ continue
+ related_model = relation.related_model
+ related_field_name = relation.remote_field.name
+ if not issubclass(related_model, ChangeLoggingMixin):
+ # We only care about triggering the m2m_changed signal for models which support
+ # change logging
+ continue
+ for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
+ obj.snapshot() # Ensure the change record includes the "before" state
+ getattr(obj, related_field_name).remove(instance)
+
# Enqueue webhooks
queue = events_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
@@ -186,45 +218,17 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
# Custom validation
#
-def run_validators(instance, validators):
-
- for validator in validators:
-
- # Loading a validator class by dotted path
- if type(validator) is str:
- module, cls = validator.rsplit('.', 1)
- validator = getattr(importlib.import_module(module), cls)()
-
- # Constructing a new instance on the fly from a ruleset
- elif type(validator) is dict:
- validator = CustomValidator(validator)
-
- validator(instance)
-
-
@receiver(post_clean)
def run_save_validators(sender, instance, **kwargs):
+ """
+ Run any custom validation rules for the model prior to calling save().
+ """
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
run_validators(instance, validators)
-@receiver(pre_delete)
-def run_delete_validators(sender, instance, **kwargs):
- model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
- validators = get_config().PROTECTION_RULES.get(model_name, [])
-
- try:
- run_validators(instance, validators)
- except ValidationError as e:
- raise AbortRequest(
- _("Deletion is prevented by a protection rule: {message}").format(
- message=e
- )
- )
-
-
#
# Tags
#
diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py
index 35f61958c..30c9397d5 100644
--- a/netbox/extras/validators.py
+++ b/netbox/extras/validators.py
@@ -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)
diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py
index 17d8d74a7..c012eca6d 100644
--- a/netbox/ipam/api/nested_serializers.py
+++ b/netbox/ipam/api/nested_serializers.py
@@ -116,10 +116,11 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer):
class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
+ group = NestedFHRPGroupSerializer()
class Meta:
model = models.FHRPGroupAssignment
- fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority']
+ fields = ['id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority']
#
diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py
index 4439e82b4..62e2b9eca 100644
--- a/netbox/ipam/api/views.py
+++ b/netbox/ipam/api/views.py
@@ -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
diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py
index 2d55deae4..20341005d 100644
--- a/netbox/ipam/fields.py
+++ b/netbox/ipam/fields.py
@@ -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)
diff --git a/netbox/ipam/formfields.py b/netbox/ipam/formfields.py
index e8d171d7f..75040e89c 100644
--- a/netbox/ipam/formfields.py
+++ b/netbox/ipam/formfields.py
@@ -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."))
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index 34b7c5958..c7e3f92a3 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -751,4 +751,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."))
diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py
index cb633e162..447415a69 100644
--- a/netbox/ipam/tests/test_api.py
+++ b/netbox/ipam/tests/test_api.py
@@ -760,7 +760,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
model = FHRPGroupAssignment
- brief_fields = ['display', 'group_id', 'id', 'interface_id', 'interface_type', 'priority', 'url']
+ brief_fields = ['display', 'group', 'id', 'interface_id', 'interface_type', 'priority', 'url']
bulk_update_data = {
'priority': 100,
}
diff --git a/netbox/ipam/validators.py b/netbox/ipam/validators.py
index 50faea8b8..3f1d5fdc3 100644
--- a/netbox/ipam/validators.py
+++ b/netbox/ipam/validators.py
@@ -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'
)
diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py
index d6e43ea75..61c43c211 100644
--- a/netbox/netbox/api/fields.py
+++ b/netbox/netbox/api/fields.py
@@ -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 '.'
"""
default_error_messages = {
- "does_not_exist": "Invalid content type: {content_type}",
- "invalid": "Invalid value. Specify a content type as '.'.",
+ "does_not_exist": _("Invalid content type: {content_type}"),
+ "invalid": _("Invalid value. Specify a content type as '.'."),
}
def to_internal_value(self, data):
diff --git a/netbox/netbox/api/serializers/nested.py b/netbox/netbox/api/serializers/nested.py
index 95dcd560c..027f3d11e 100644
--- a/netbox/netbox/api/serializers/nested.py
+++ b/netbox/netbox/api/serializers/nested.py
@@ -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
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index 1b0d50ca5..10555b373 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -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 = {
diff --git a/netbox/netbox/config/__init__.py b/netbox/netbox/config/__init__.py
index c536ceadb..1c16d6769 100644
--- a/netbox/netbox/config/__init__.py
+++ b/netbox/netbox/config/__init__.py
@@ -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"""
diff --git a/netbox/netbox/forms/mixins.py b/netbox/netbox/forms/mixins.py
index d76eb56c8..815f1f6fa 100644
--- a/netbox/netbox/forms/mixins.py
+++ b/netbox/netbox/forms/mixins.py
@@ -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):
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index a13b84bed..da7b38f56 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -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):
@@ -489,10 +493,10 @@ class SyncedDataMixin(models.Model):
# Create/delete AutoSyncRecord as needed
content_type = ContentType.objects.get_for_model(self)
if self.auto_sync_enabled:
- AutoSyncRecord.objects.get_or_create(
- datafile=self.data_file,
+ AutoSyncRecord.objects.update_or_create(
object_type=content_type,
- object_id=self.pk
+ object_id=self.pk,
+ defaults={'datafile': self.data_file}
)
else:
AutoSyncRecord.objects.filter(
@@ -547,7 +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__
+ ))
#
diff --git a/netbox/netbox/plugins/navigation.py b/netbox/netbox/plugins/navigation.py
index 2075c97b6..aae569412 100644
--- a/netbox/netbox/plugins/navigation.py
+++ b/netbox/netbox/plugins/navigation.py
@@ -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
diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py
index 3be538441..fd247a82a 100644
--- a/netbox/netbox/plugins/registration.py
+++ b/netbox/netbox/plugins/registration.py
@@ -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
diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py
index e9b9a9dca..85229dbaf 100644
--- a/netbox/netbox/plugins/templates.py
+++ b/netbox/netbox/plugins/templates.py
@@ -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})
diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py
index ad8c18dcf..d783647ec 100644
--- a/netbox/netbox/registry.py
+++ b/netbox/netbox/registry.py
@@ -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
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index d9f0c4cf8..0c4a9102a 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
# Environment setup
#
-VERSION = '3.7.2'
+VERSION = '3.7.3'
# 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',
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index 615db6181..0aa681f5d 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -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
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index 90b6e9495..6277e1c5b 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -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):
"""
diff --git a/netbox/templates/account/profile.html b/netbox/templates/account/profile.html
index cb699072c..88e048f2f 100644
--- a/netbox/templates/account/profile.html
+++ b/netbox/templates/account/profile.html
@@ -34,6 +34,10 @@