Przeglądaj źródła

15094 Add missing gettext to error strings for internationalization (#15155)

* 15049 add missing gettext to error strings

* 15049 add missing gettext to error strings

* 15094 review change

* 15094 review change

* Formatting cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Arthur Hanson 2 lat temu
rodzic
commit
af27bf5eff
47 zmienionych plików z 275 dodań i 133 usunięć
  1. 2 2
      netbox/circuits/models/circuits.py
  2. 1 1
      netbox/core/data_backends.py
  3. 2 2
      netbox/core/forms/model_forms.py
  4. 2 2
      netbox/core/models/data.py
  5. 5 1
      netbox/core/models/jobs.py
  6. 3 2
      netbox/dcim/fields.py
  7. 20 5
      netbox/dcim/forms/bulk_import.py
  8. 18 10
      netbox/dcim/models/cables.py
  9. 2 1
      netbox/dcim/tests/test_api.py
  10. 2 1
      netbox/extras/api/customfields.py
  11. 6 5
      netbox/extras/api/serializers.py
  12. 12 6
      netbox/extras/conditions.py
  13. 2 1
      netbox/extras/dashboard/utils.py
  14. 5 3
      netbox/extras/dashboard/widgets.py
  15. 5 2
      netbox/extras/events.py
  16. 2 2
      netbox/extras/forms/bulk_import.py
  17. 2 1
      netbox/extras/management/commands/reindex.py
  18. 4 3
      netbox/extras/scripts.py
  19. 2 1
      netbox/ipam/api/views.py
  20. 2 1
      netbox/ipam/fields.py
  21. 8 7
      netbox/ipam/formfields.py
  22. 1 1
      netbox/ipam/forms/model_forms.py
  23. 9 4
      netbox/ipam/validators.py
  24. 6 5
      netbox/netbox/api/fields.py
  25. 11 5
      netbox/netbox/api/serializers/nested.py
  26. 4 1
      netbox/netbox/authentication.py
  27. 2 1
      netbox/netbox/config/__init__.py
  28. 3 1
      netbox/netbox/forms/mixins.py
  29. 10 4
      netbox/netbox/models/features.py
  30. 5 4
      netbox/netbox/plugins/navigation.py
  31. 25 6
      netbox/netbox/plugins/registration.py
  32. 2 1
      netbox/netbox/plugins/templates.py
  33. 4 3
      netbox/netbox/registry.py
  34. 2 1
      netbox/netbox/views/generic/bulk_views.py
  35. 4 1
      netbox/netbox/views/generic/object_views.py
  36. 1 1
      netbox/utilities/fields.py
  37. 2 2
      netbox/utilities/forms/bulk_import.py
  38. 22 11
      netbox/utilities/forms/utils.py
  39. 11 2
      netbox/utilities/forms/widgets/apiselect.py
  40. 3 2
      netbox/utilities/permissions.py
  41. 2 1
      netbox/utilities/request.py
  42. 4 1
      netbox/utilities/tables.py
  43. 2 1
      netbox/utilities/testing/views.py
  44. 17 8
      netbox/utilities/utils.py
  45. 2 1
      netbox/utilities/validators.py
  46. 11 4
      netbox/utilities/views.py
  47. 3 2
      netbox/wireless/utils.py

+ 2 - 2
netbox/circuits/models/circuits.py

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

+ 1 - 1
netbox/core/data_backends.py

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

+ 2 - 2
netbox/core/forms/model_forms.py

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

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

+ 5 - 1
netbox/core/models/jobs.py

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

+ 3 - 2
netbox/dcim/fields.py

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

+ 20 - 5
netbox/dcim/forms/bulk_import.py

@@ -870,7 +870,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
     def clean_vdcs(self):
     def clean_vdcs(self):
         for vdc in self.cleaned_data['vdcs']:
         for vdc in self.cleaned_data['vdcs']:
             if vdc.device != self.cleaned_data['device']:
             if vdc.device != self.cleaned_data['device']:
-                raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
+                raise forms.ValidationError(
+                    _("VDC {vdc} is not assigned to device {device}").format(
+                        vdc=vdc, device=self.cleaned_data['device']
+                    )
+                )
         return self.cleaned_data['vdcs']
         return self.cleaned_data['vdcs']
 
 
 
 
@@ -1075,7 +1079,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
             component = model.objects.get(device=device, name=component_name)
             component = model.objects.get(device=device, name=component_name)
             self.instance.component = component
             self.instance.component = component
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
-            raise forms.ValidationError(f"Component not found: {device} - {component_name}")
+            raise forms.ValidationError(
+                _("Component not found: {device} - {component_name}").format(
+                    device=device, component_name=component_name
+                )
+            )
 
 
 
 
 #
 #
@@ -1193,10 +1201,17 @@ class CableImportForm(NetBoxModelImportForm):
             else:
             else:
                 termination_object = model.objects.get(device=device, name=name)
                 termination_object = model.objects.get(device=device, name=name)
             if termination_object.cable is not None and termination_object.cable != self.instance:
             if termination_object.cable is not None and termination_object.cable != self.instance:
-                raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
+                raise forms.ValidationError(
+                    _("Side {side_upper}: {device} {termination_object} is already connected").format(
+                        side_upper=side.upper(), device=device, termination_object=termination_object
+                    )
+                )
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
-            raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
-
+            raise forms.ValidationError(
+                _("{side_upper} side termination not found: {device} {name}").format(
+                    side_upper=side.upper(), device=device, name=name
+                )
+            )
         setattr(self.instance, f'{side}_terminations', [termination_object])
         setattr(self.instance, f'{side}_terminations', [termination_object])
         return termination_object
         return termination_object
 
 

+ 18 - 10
netbox/dcim/models/cables.py

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

+ 2 - 1
netbox/dcim/tests/test_api.py

@@ -1,6 +1,7 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.test import override_settings
 from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext as _
 from rest_framework import status
 from rest_framework import status
 
 
 from dcim.choices import *
 from dcim.choices import *
@@ -45,7 +46,7 @@ class Mixins:
                 name='Peer Device'
                 name='Peer Device'
             )
             )
             if self.peer_termination_type is None:
             if self.peer_termination_type is None:
-                raise NotImplementedError("Test case must set peer_termination_type")
+                raise NotImplementedError(_("Test case must set peer_termination_type"))
             peer_obj = self.peer_termination_type.objects.create(
             peer_obj = self.peer_termination_type.objects.create(
                 device=peer_device,
                 device=peer_device,
                 name='Peer Termination'
                 name='Peer Termination'

+ 2 - 1
netbox/extras/api/customfields.py

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

+ 6 - 5
netbox/extras/api/serializers.py

@@ -1,5 +1,6 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
+from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
@@ -150,7 +151,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
 
 
     def validate_type(self, value):
     def validate_type(self, value):
         if self.instance and self.instance.type != value:
         if self.instance and self.instance.type != value:
-            raise serializers.ValidationError('Changing the type of custom fields is not supported.')
+            raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
 
 
         return value
         return value
 
 
@@ -545,12 +546,12 @@ class ReportInputSerializer(serializers.Serializer):
 
 
     def validate_schedule_at(self, value):
     def validate_schedule_at(self, value):
         if value and not self.context['report'].scheduling_enabled:
         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
         return value
 
 
     def validate_interval(self, value):
     def validate_interval(self, value):
         if value and not self.context['report'].scheduling_enabled:
         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
         return value
 
 
 
 
@@ -595,12 +596,12 @@ class ScriptInputSerializer(serializers.Serializer):
 
 
     def validate_schedule_at(self, value):
     def validate_schedule_at(self, value):
         if value and not self.context['script'].scheduling_enabled:
         if value and not self.context['script'].scheduling_enabled:
-            raise serializers.ValidationError("Scheduling is not enabled for this script.")
+            raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
         return value
         return value
 
 
     def validate_interval(self, value):
     def validate_interval(self, value):
         if value and not self.context['script'].scheduling_enabled:
         if value and not self.context['script'].scheduling_enabled:
-            raise serializers.ValidationError("Scheduling is not enabled for this script.")
+            raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
         return value
         return value
 
 
 
 

+ 12 - 6
netbox/extras/conditions.py

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

+ 2 - 1
netbox/extras/dashboard/utils.py

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

+ 5 - 3
netbox/extras/dashboard/widgets.py

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

+ 5 - 2
netbox/extras/events.py

@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
+from django.utils.translation import gettext as _
 from django_rq import get_queue
 from django_rq import get_queue
 
 
 from core.models import Job
 from core.models import Job
@@ -129,7 +130,9 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
             )
             )
 
 
         else:
         else:
-            raise ValueError(f"Unknown action type for an event rule: {event_rule.action_type}")
+            raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
+                action_type=event_rule.action_type
+            ))
 
 
 
 
 def process_event_queue(events):
 def process_event_queue(events):
@@ -175,4 +178,4 @@ def flush_events(queue):
                 func = import_string(name)
                 func = import_string(name)
                 func(queue)
                 func(queue)
             except Exception as e:
             except Exception as e:
-                logger.error(f"Cannot import events pipeline {name} error: {e}")
+                logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))

+ 2 - 2
netbox/extras/forms/bulk_import.py

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

+ 2 - 1
netbox/extras/management/commands/reindex.py

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

+ 4 - 3
netbox/extras/scripts.py

@@ -11,6 +11,7 @@ from django.conf import settings
 from django.core.validators import RegexValidator
 from django.core.validators import RegexValidator
 from django.db import transaction
 from django.db import transaction
 from django.utils.functional import classproperty
 from django.utils.functional import classproperty
+from django.utils.translation import gettext as _
 
 
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
 from core.models import Job
 from core.models import Job
@@ -356,7 +357,7 @@ class BaseScript:
         return ordered_vars
         return ordered_vars
 
 
     def run(self, data, commit):
     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
     # Form rendering
 
 
@@ -367,11 +368,11 @@ class BaseScript:
             fieldsets.extend(self.fieldsets)
             fieldsets.extend(self.fieldsets)
         else:
         else:
             fields = list(name for name, _ in self._get_vars().items())
             fields = list(name for name, _ in self._get_vars().items())
-            fieldsets.append(('Script Data', fields))
+            fieldsets.append((_('Script Data'), fields))
 
 
         # Append the default fieldset if defined in the Meta class
         # Append the default fieldset if defined in the Meta class
         exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
         exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
-        fieldsets.append(('Script Execution Parameters', exec_parameters))
+        fieldsets.append((_('Script Execution Parameters'), exec_parameters))
 
 
         return fieldsets
         return fieldsets
 
 

+ 2 - 1
netbox/ipam/api/views.py

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

+ 2 - 1
netbox/ipam/fields.py

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

+ 8 - 7
netbox/ipam/formfields.py

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

+ 1 - 1
netbox/ipam/forms/model_forms.py

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

+ 9 - 4
netbox/ipam/validators.py

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

+ 6 - 5
netbox/netbox/api/fields.py

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

+ 11 - 5
netbox/netbox/api/serializers/nested.py

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

+ 4 - 1
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.contrib.auth.models import Group, AnonymousUser
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
 from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
 
 
 from users.constants import CONSTRAINT_TOKEN_USER
 from users.constants import CONSTRAINT_TOKEN_USER
 from users.models import ObjectPermission
 from users.models import ObjectPermission
@@ -132,7 +133,9 @@ class ObjectPermissionMixin:
         # Sanity check: Ensure that the requested permission applies to the specified object
         # Sanity check: Ensure that the requested permission applies to the specified object
         model = obj._meta.concrete_model
         model = obj._meta.concrete_model
         if model._meta.label_lower != '.'.join((app_label, model_name)):
         if model._meta.label_lower != '.'.join((app_label, model_name)):
-            raise ValueError(f"Invalid permission {perm} for model {model}")
+            raise ValueError(_("Invalid permission {permission} for model {model}").format(
+                permission=perm, model=model
+            ))
 
 
         # Compile a QuerySet filter that matches all instances of the specified model
         # Compile a QuerySet filter that matches all instances of the specified model
         tokens = {
         tokens = {

+ 2 - 1
netbox/netbox/config/__init__.py

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

+ 3 - 1
netbox/netbox/forms/mixins.py

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

+ 10 - 4
netbox/netbox/models/features.py

@@ -275,16 +275,20 @@ class CustomFieldsMixin(models.Model):
         # Validate all field values
         # Validate all field values
         for field_name, value in self.custom_field_data.items():
         for field_name, value in self.custom_field_data.items():
             if field_name not in custom_fields:
             if field_name not in custom_fields:
-                raise ValidationError(f"Unknown field name '{field_name}' in custom field data.")
+                raise ValidationError(_("Unknown field name '{name}' in custom field data.").format(
+                    name=field_name
+                ))
             try:
             try:
                 custom_fields[field_name].validate(value)
                 custom_fields[field_name].validate(value)
             except ValidationError as e:
             except ValidationError as e:
-                raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}")
+                raise ValidationError(_("Invalid value for custom field '{name}': {error}").format(
+                    name=field_name, error=e.message
+                ))
 
 
         # Check for missing required values
         # Check for missing required values
         for cf in custom_fields.values():
         for cf in custom_fields.values():
             if cf.required and cf.name not in self.custom_field_data:
             if cf.required and cf.name not in self.custom_field_data:
-                raise ValidationError(f"Missing required custom field '{cf.name}'.")
+                raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
 
 
 
 
 class CustomLinksMixin(models.Model):
 class CustomLinksMixin(models.Model):
@@ -547,7 +551,9 @@ class SyncedDataMixin(models.Model):
         Inheriting models must override this method with specific logic to copy data from the assigned DataFile
         Inheriting models must override this method with specific logic to copy data from the assigned DataFile
         to the local instance. This method should *NOT* call save() on the instance.
         to the local instance. This method should *NOT* call save() on the instance.
         """
         """
-        raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.")
+        raise NotImplementedError(_("{class_name} must implement a sync_data() method.").format(
+            class_name=self.__class__
+        ))
 
 
 
 
 #
 #

+ 5 - 4
netbox/netbox/plugins/navigation.py

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

+ 25 - 6
netbox/netbox/plugins/registration.py

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

+ 2 - 1
netbox/netbox/plugins/templates.py

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

+ 4 - 3
netbox/netbox/registry.py

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

+ 2 - 1
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.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
 from django_tables2.export import TableExport
 from django_tables2.export import TableExport
 
 
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
@@ -390,7 +391,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                 try:
                 try:
                     instance = prefetched_objects[object_id]
                     instance = prefetched_objects[object_id]
                 except KeyError:
                 except KeyError:
-                    form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
+                    form.add_error('data', _("Row {i}: Object with ID {id} does not exist").format(i=i, id=object_id))
                     raise ValidationError('')
                     raise ValidationError('')
 
 
                 # Take a snapshot for change logging
                 # Take a snapshot for change logging

+ 4 - 1
netbox/netbox/views/generic/object_views.py

@@ -11,6 +11,7 @@ from django.shortcuts import redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
 
 
 from extras.signals import clear_events
 from extras.signals import clear_events
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
@@ -101,7 +102,9 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
             request: The current request
             request: The current request
             parent: The parent object
             parent: The parent object
         """
         """
-        raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
+        raise NotImplementedError(_('{class_name} must implement get_children()').format(
+            class_name=self.__class__.__name__
+        ))
 
 
     def prep_table_data(self, request, queryset, parent):
     def prep_table_data(self, request, queryset, parent):
         """
         """

+ 1 - 1
netbox/utilities/fields.py

@@ -93,7 +93,7 @@ class RestrictedGenericForeignKey(GenericForeignKey):
         if type(queryset) is dict:
         if type(queryset) is dict:
             restrict_params = queryset
             restrict_params = queryset
         elif queryset is not None:
         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
         # For efficiency, group the instances by content type and then do one
         # query per model
         # query per model

+ 2 - 2
netbox/utilities/forms/bulk_import.py

@@ -49,7 +49,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
 
 
         # Determine whether we're reading from form data or an uploaded file
         # Determine whether we're reading from form data or an uploaded file
         if self.cleaned_data['data'] and import_method != ImportMethodChoices.DIRECT:
         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:
         if import_method == ImportMethodChoices.UPLOAD:
             self.upload_file = 'upload_file'
             self.upload_file = 'upload_file'
             file = self.files.get('upload_file')
             file = self.files.get('upload_file')
@@ -78,7 +78,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
         elif format == ImportFormatChoices.YAML:
         elif format == ImportFormatChoices.YAML:
             self.cleaned_data['data'] = self._clean_yaml(data)
             self.cleaned_data['data'] = self._clean_yaml(data)
         else:
         else:
-            raise forms.ValidationError(f"Unknown data format: {format}")
+            raise forms.ValidationError(_("Unknown data format: {format}").format(format=format))
 
 
     def _detect_format(self, data):
     def _detect_format(self, data):
         """
         """

+ 22 - 11
netbox/utilities/forms/utils.py

@@ -2,6 +2,7 @@ import re
 
 
 from django import forms
 from django import forms
 from django.forms.models import fields_for_model
 from django.forms.models import fields_for_model
+from django.utils.translation import gettext as _
 
 
 from utilities.choices import unpack_grouped_choices
 from utilities.choices import unpack_grouped_choices
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
@@ -38,7 +39,7 @@ def parse_numeric_range(string, base=10):
         try:
         try:
             begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
             begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
         except ValueError:
         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))
         values.extend(range(begin, end))
     return sorted(set(values))
     return sorted(set(values))
 
 
@@ -61,7 +62,7 @@ def parse_alphanumeric_range(string):
             begin, end = dash_range, dash_range
             begin, end = dash_range, dash_range
         if begin.isdigit() and end.isdigit():
         if begin.isdigit() and end.isdigit():
             if int(begin) >= int(end):
             if int(begin) >= int(end):
-                raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
+                raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
 
 
             for n in list(range(int(begin), int(end) + 1)):
             for n in list(range(int(begin), int(end) + 1)):
                 values.append(n)
                 values.append(n)
@@ -73,10 +74,10 @@ def parse_alphanumeric_range(string):
             else:
             else:
                 # Not a valid range (more than a single character)
                 # Not a valid range (more than a single character)
                 if not len(begin) == len(end) == 1:
                 if not len(begin) == len(end) == 1:
-                    raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
+                    raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
 
 
                 if ord(begin) >= ord(end):
                 if ord(begin) >= ord(end):
-                    raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
+                    raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
 
 
                 for n in list(range(ord(begin), ord(end) + 1)):
                 for n in list(range(ord(begin), ord(end) + 1)):
                     values.append(chr(n))
                     values.append(chr(n))
@@ -221,18 +222,24 @@ def parse_csv(reader):
         if '.' in header:
         if '.' in header:
             field, to_field = header.split('.', 1)
             field, to_field = header.split('.', 1)
             if field in headers:
             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
             headers[field] = to_field
         else:
         else:
             if header in headers:
             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
             headers[header] = None
 
 
     # Parse CSV rows into a list of dictionaries mapped from the column headers.
     # Parse CSV rows into a list of dictionaries mapped from the column headers.
     for i, row in enumerate(reader, start=1):
     for i, row in enumerate(reader, start=1):
         if len(row) != len(headers):
         if len(row) != len(headers):
             raise forms.ValidationError(
             raise forms.ValidationError(
-                f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
+                _("Row {i}: Expected {count_expected} columns but found {count_found}").format(
+                    count_expected=len(headers), count_found=len(row)
+                )
             )
             )
         row = [col.strip() for col in row]
         row = [col.strip() for col in row]
         record = dict(zip(headers.keys(), row))
         record = dict(zip(headers.keys(), row))
@@ -253,14 +260,18 @@ def validate_csv(headers, fields, required_fields):
             is_update = True
             is_update = True
             continue
             continue
         if field not in fields:
         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'):
         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):
         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)
     # Validate required fields (if not an update)
     if not is_update:
     if not is_update:
         for f in required_fields:
         for f in required_fields:
             if f not in headers:
             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))

+ 11 - 2
netbox/utilities/forms/widgets/apiselect.py

@@ -3,6 +3,7 @@ from typing import Dict, List, Tuple
 
 
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
+from django.utils.translation import gettext_lazy as _
 
 
 __all__ = (
 __all__ = (
     'APISelect',
     'APISelect',
@@ -119,7 +120,11 @@ class APISelect(forms.Select):
                 update = [{'fieldName': f, 'queryParam': q} for (f, q) in self.dynamic_params.items()]
                 update = [{'fieldName': f, 'queryParam': q} for (f, q) in self.dynamic_params.items()]
                 self._serialize_params(key, update)
                 self._serialize_params(key, update)
             except IndexError as error:
             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):
     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()]
                 update = [{'queryParam': k, 'queryValue': v} for (k, v) in self.static_params.items()]
                 self._serialize_params(key, update)
                 self._serialize_params(key, update)
             except IndexError as error:
             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):
     def add_query_params(self, query_params):
         """
         """

+ 3 - 2
netbox/utilities/permissions.py

@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
 
 
 __all__ = (
 __all__ = (
     'get_permission_for_model',
     'get_permission_for_model',
@@ -36,7 +37,7 @@ def resolve_permission(name):
         action, model_name = codename.rsplit('_', 1)
         action, model_name = codename.rsplit('_', 1)
     except ValueError:
     except ValueError:
         raise 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
     return app_label, action, model_name
@@ -53,7 +54,7 @@ def resolve_permission_ct(name):
     try:
     try:
         content_type = ContentType.objects.get(app_label=app_label, model=model_name)
         content_type = ContentType.objects.get(app_label=app_label, model=model_name)
     except ContentType.DoesNotExist:
     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
     return content_type, action
 
 

+ 2 - 1
netbox/utilities/request.py

@@ -1,3 +1,4 @@
+from django.utils.translation import gettext_lazy as _
 from netaddr import AddrFormatError, IPAddress
 from netaddr import AddrFormatError, IPAddress
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
@@ -29,7 +30,7 @@ def get_client_ip(request, additional_headers=()):
                 return IPAddress(ip)
                 return IPAddress(ip)
             except AddrFormatError:
             except AddrFormatError:
                 # We did our best
                 # 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
     # Could not determine the client IP address from request headers
     return None
     return None

+ 4 - 1
netbox/utilities/tables.py

@@ -1,3 +1,4 @@
+from django.utils.translation import gettext_lazy as _
 from netbox.registry import registry
 from netbox.registry import registry
 
 
 __all__ = (
 __all__ = (
@@ -43,5 +44,7 @@ def register_table_column(column, name, *tables):
     for table in tables:
     for table in tables:
         reg = registry['tables'][table]
         reg = registry['tables'][table]
         if name in reg:
         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
         reg[name] = column

+ 2 - 1
netbox/utilities/testing/views.py

@@ -6,6 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import ForeignKey
 from django.db.models import ForeignKey
 from django.test import override_settings
 from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext as _
 
 
 from extras.choices import ObjectChangeActionChoices
 from extras.choices import ObjectChangeActionChoices
 from extras.models import ObjectChange
 from extras.models import ObjectChange
@@ -621,7 +622,7 @@ class ViewTestCases:
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
         def test_bulk_update_objects_with_permission(self):
         def test_bulk_update_objects_with_permission(self):
             if not hasattr(self, 'csv_update_data'):
             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()
             initial_count = self._get_queryset().count()
             array, csv_data = self._get_update_csv_data()
             array, csv_data = self._get_update_csv_data()

+ 17 - 8
netbox/utilities/utils.py

@@ -15,6 +15,7 @@ from django.utils import timezone
 from django.utils.datastructures import MultiValueDict
 from django.utils.datastructures import MultiValueDict
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.timezone import localtime
 from django.utils.timezone import localtime
+from django.utils.translation import gettext as _
 from jinja2.sandbox import SandboxedEnvironment
 from jinja2.sandbox import SandboxedEnvironment
 from mptt.models import MPTTModel
 from mptt.models import MPTTModel
 
 
@@ -306,13 +307,17 @@ def to_meters(length, unit):
     """
     """
     try:
     try:
         if length < 0:
         if length < 0:
-            raise ValueError("Length must be a positive number")
+            raise ValueError(_("Length must be a positive number"))
     except TypeError:
     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()
     valid_units = CableLengthUnitChoices.values()
     if unit not in valid_units:
     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:
     if unit == CableLengthUnitChoices.UNIT_KILOMETER:
         return length * 1000
         return length * 1000
@@ -326,7 +331,7 @@ def to_meters(length, unit):
         return length * Decimal(0.3048)
         return length * Decimal(0.3048)
     if unit == CableLengthUnitChoices.UNIT_INCH:
     if unit == CableLengthUnitChoices.UNIT_INCH:
         return length * Decimal(0.0254)
         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):
 def to_grams(weight, unit):
@@ -335,13 +340,17 @@ def to_grams(weight, unit):
     """
     """
     try:
     try:
         if weight < 0:
         if weight < 0:
-            raise ValueError("Weight must be a positive number")
+            raise ValueError(_("Weight must be a positive number"))
     except TypeError:
     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()
     valid_units = WeightUnitChoices.values()
     if unit not in valid_units:
     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:
     if unit == WeightUnitChoices.UNIT_KILOGRAM:
         return weight * 1000
         return weight * 1000
@@ -351,7 +360,7 @@ def to_grams(weight, unit):
         return weight * Decimal(453.592)
         return weight * Decimal(453.592)
     if unit == WeightUnitChoices.UNIT_OUNCE:
     if unit == WeightUnitChoices.UNIT_OUNCE:
         return weight * Decimal(28.3495)
         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):
 def render_jinja2(template_code, context):

+ 2 - 1
netbox/utilities/validators.py

@@ -2,6 +2,7 @@ import re
 
 
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import BaseValidator, RegexValidator, URLValidator, _lazy_re_compile
 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
 from netbox.config import get_config
 
 
@@ -61,4 +62,4 @@ def validate_regex(value):
     try:
     try:
         re.compile(value)
         re.compile(value)
     except re.error:
     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))

+ 11 - 4
netbox/utilities/views.py

@@ -2,6 +2,7 @@ from django.contrib.auth.mixins import AccessMixin
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.urls import reverse
 from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
 from django.urls.exceptions import NoReverseMatch
+from django.utils.translation import gettext_lazy as _
 
 
 from netbox.registry import registry
 from netbox.registry import registry
 from .permissions import resolve_permission
 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.
         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):
     def has_permission(self):
         user = self.request.user
         user = self.request.user
@@ -68,7 +71,9 @@ class ObjectPermissionRequiredMixin(AccessMixin):
         """
         """
         Return the specific permission necessary to perform the requested action on an object.
         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):
     def has_permission(self):
         user = self.request.user
         user = self.request.user
@@ -89,8 +94,10 @@ class ObjectPermissionRequiredMixin(AccessMixin):
 
 
         if not hasattr(self, 'queryset'):
         if not hasattr(self, 'queryset'):
             raise ImproperlyConfigured(
             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():
         if not self.has_permission():

+ 3 - 2
netbox/wireless/utils.py

@@ -1,4 +1,5 @@
 from decimal import Decimal
 from decimal import Decimal
+from django.utils.translation import gettext_lazy as _
 
 
 from .choices import WirelessChannelChoices
 from .choices import WirelessChannelChoices
 
 
@@ -12,7 +13,7 @@ def get_channel_attr(channel, attr):
     Return the specified attribute of a given WirelessChannelChoices value.
     Return the specified attribute of a given WirelessChannelChoices value.
     """
     """
     if channel not in WirelessChannelChoices.values():
     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('-')
     channel_values = channel.split('-')
     attrs = {
     attrs = {
@@ -22,6 +23,6 @@ def get_channel_attr(channel, attr):
         'width': Decimal(channel_values[3]),
         'width': Decimal(channel_values[3]),
     }
     }
     if attr not in attrs:
     if attr not in attrs:
-        raise ValueError(f"Invalid channel attribute: {attr}")
+        raise ValueError(_("Invalid channel attribute: {name}").format(name=attr))
 
 
     return attrs[attr]
     return attrs[attr]