Просмотр исходного кода

Closes #13132: Wrap verbose_name and other model text with gettext_lazy() (i18n)

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Arthur Hanson 2 лет назад
Родитель
Сommit
83bebc1bd2
36 измененных файлов с 899 добавлено и 431 удалено
  1. 16 14
      netbox/circuits/models/circuits.py
  2. 8 4
      netbox/circuits/models/providers.py
  3. 14 2
      netbox/core/models/data.py
  4. 5 1
      netbox/core/models/files.py
  5. 10 1
      netbox/core/models/jobs.py
  6. 12 1
      netbox/dcim/models/cables.py
  7. 45 26
      netbox/dcim/models/device_component_templates.py
  8. 145 80
      netbox/dcim/models/device_components.py
  9. 110 59
      netbox/dcim/models/devices.py
  10. 4 1
      netbox/dcim/models/mixins.py
  11. 15 4
      netbox/dcim/models/power.py
  12. 28 18
      netbox/dcim/models/racks.py
  13. 24 13
      netbox/dcim/models/sites.py
  14. 7 0
      netbox/extras/models/change_logging.py
  15. 11 3
      netbox/extras/models/configs.py
  16. 84 49
      netbox/extras/models/customfields.py
  17. 3 0
      netbox/extras/models/dashboard.py
  18. 91 37
      netbox/extras/models/models.py
  19. 8 1
      netbox/extras/models/search.py
  20. 5 0
      netbox/extras/models/staging.py
  21. 3 1
      netbox/extras/models/tags.py
  22. 17 7
      netbox/ipam/models/asns.py
  23. 8 4
      netbox/ipam/models/fhrp.py
  24. 62 33
      netbox/ipam/models/ip.py
  25. 14 4
      netbox/ipam/models/l2vpn.py
  26. 10 7
      netbox/ipam/models/services.py
  27. 17 11
      netbox/ipam/models/vlans.py
  28. 5 3
      netbox/ipam/models/vrfs.py
  29. 10 1
      netbox/netbox/models/__init__.py
  30. 6 1
      netbox/netbox/models/features.py
  31. 8 0
      netbox/tenancy/models/contacts.py
  32. 7 2
      netbox/tenancy/models/tenants.py
  33. 22 8
      netbox/users/models.py
  34. 5 1
      netbox/virtualization/models/clusters.py
  35. 40 24
      netbox/virtualization/models/virtualmachines.py
  36. 20 10
      netbox/wireless/models.py

+ 16 - 14
netbox/circuits/models/circuits.py

@@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 
 from circuits.choices import *
 from circuits.choices import *
 from dcim.models import CabledObjectModel
 from dcim.models import CabledObjectModel
@@ -34,8 +34,8 @@ class Circuit(PrimaryModel):
     """
     """
     cid = models.CharField(
     cid = models.CharField(
         max_length=100,
         max_length=100,
-        verbose_name='Circuit ID',
-        help_text=_("Unique circuit ID")
+        verbose_name=_('circuit ID'),
+        help_text=_('Unique circuit ID')
     )
     )
     provider = models.ForeignKey(
     provider = models.ForeignKey(
         to='circuits.Provider',
         to='circuits.Provider',
@@ -55,6 +55,7 @@ class Circuit(PrimaryModel):
         related_name='circuits'
         related_name='circuits'
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=CircuitStatusChoices,
         choices=CircuitStatusChoices,
         default=CircuitStatusChoices.STATUS_ACTIVE
         default=CircuitStatusChoices.STATUS_ACTIVE
@@ -69,17 +70,17 @@ class Circuit(PrimaryModel):
     install_date = models.DateField(
     install_date = models.DateField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Installed'
+        verbose_name=_('installed')
     )
     )
     termination_date = models.DateField(
     termination_date = models.DateField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Terminates'
+        verbose_name=_('terminates')
     )
     )
     commit_rate = models.PositiveIntegerField(
     commit_rate = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Commit rate (Kbps)',
+        verbose_name=_('commit rate (Kbps)'),
         help_text=_("Committed rate")
         help_text=_("Committed rate")
     )
     )
 
 
@@ -162,7 +163,7 @@ class CircuitTermination(
     term_side = models.CharField(
     term_side = models.CharField(
         max_length=1,
         max_length=1,
         choices=CircuitTerminationSideChoices,
         choices=CircuitTerminationSideChoices,
-        verbose_name='Termination'
+        verbose_name=_('termination')
     )
     )
     site = models.ForeignKey(
     site = models.ForeignKey(
         to='dcim.Site',
         to='dcim.Site',
@@ -179,30 +180,31 @@ class CircuitTermination(
         null=True
         null=True
     )
     )
     port_speed = models.PositiveIntegerField(
     port_speed = models.PositiveIntegerField(
-        verbose_name='Port speed (Kbps)',
+        verbose_name=_('port speed (Kbps)'),
         blank=True,
         blank=True,
         null=True,
         null=True,
-        help_text=_("Physical circuit speed")
+        help_text=_('Physical circuit speed')
     )
     )
     upstream_speed = models.PositiveIntegerField(
     upstream_speed = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Upstream speed (Kbps)',
+        verbose_name=_('upstream speed (Kbps)'),
         help_text=_('Upstream speed, if different from port speed')
         help_text=_('Upstream speed, if different from port speed')
     )
     )
     xconnect_id = models.CharField(
     xconnect_id = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
-        verbose_name='Cross-connect ID',
-        help_text=_("ID of the local cross-connect")
+        verbose_name=_('cross-connect ID'),
+        help_text=_('ID of the local cross-connect')
     )
     )
     pp_info = models.CharField(
     pp_info = models.CharField(
         max_length=100,
         max_length=100,
         blank=True,
         blank=True,
-        verbose_name='Patch panel/port(s)',
-        help_text=_("Patch panel ID and port number(s)")
+        verbose_name=_('patch panel/port(s)'),
+        help_text=_('Patch panel ID and port number(s)')
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )

+ 8 - 4
netbox/circuits/models/providers.py

@@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.db.models import Q
 from django.db.models import Q
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 
 
@@ -19,11 +19,13 @@ class Provider(PrimaryModel):
     stores information pertinent to the user's relationship with the Provider.
     stores information pertinent to the user's relationship with the Provider.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True,
         unique=True,
-        help_text=_("Full name of the provider")
+        help_text=_('Full name of the provider')
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
@@ -61,9 +63,10 @@ class ProviderAccount(PrimaryModel):
     )
     )
     account = models.CharField(
     account = models.CharField(
         max_length=100,
         max_length=100,
-        verbose_name='Account ID'
+        verbose_name=_('account ID')
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         blank=True
         blank=True
     )
     )
@@ -104,6 +107,7 @@ class ProviderNetwork(PrimaryModel):
     unimportant to the user.
     unimportant to the user.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     provider = models.ForeignKey(
     provider = models.ForeignKey(
@@ -114,7 +118,7 @@ class ProviderNetwork(PrimaryModel):
     service_id = models.CharField(
     service_id = models.CharField(
         max_length=100,
         max_length=100,
         blank=True,
         blank=True,
-        verbose_name='Service ID'
+        verbose_name=_('service ID')
     )
     )
 
 
     class Meta:
     class Meta:

+ 14 - 2
netbox/core/models/data.py

@@ -39,10 +39,12 @@ class DataSource(JobsMixin, PrimaryModel):
     A remote source, such as a git repository, from which DataFiles are synchronized.
     A remote source, such as a git repository, from which DataFiles are synchronized.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=DataSourceTypeChoices,
         choices=DataSourceTypeChoices,
         default=DataSourceTypeChoices.LOCAL
         default=DataSourceTypeChoices.LOCAL
@@ -52,23 +54,28 @@ class DataSource(JobsMixin, PrimaryModel):
         verbose_name=_('URL')
         verbose_name=_('URL')
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=DataSourceStatusChoices,
         choices=DataSourceStatusChoices,
         default=DataSourceStatusChoices.NEW,
         default=DataSourceStatusChoices.NEW,
         editable=False
         editable=False
     )
     )
     enabled = models.BooleanField(
     enabled = models.BooleanField(
+        verbose_name=_('enabled'),
         default=True
         default=True
     )
     )
     ignore_rules = models.TextField(
     ignore_rules = models.TextField(
+        verbose_name=_('ignore rules'),
         blank=True,
         blank=True,
         help_text=_("Patterns (one per line) matching files to ignore when syncing")
         help_text=_("Patterns (one per line) matching files to ignore when syncing")
     )
     )
     parameters = models.JSONField(
     parameters = models.JSONField(
+        verbose_name=_('parameters'),
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
     last_synced = models.DateTimeField(
     last_synced = models.DateTimeField(
+        verbose_name=_('last synced'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         editable=False
         editable=False
@@ -239,9 +246,11 @@ class DataFile(models.Model):
     updated, or deleted only by calling DataSource.sync().
     updated, or deleted only by calling DataSource.sync().
     """
     """
     created = models.DateTimeField(
     created = models.DateTimeField(
+        verbose_name=_('created'),
         auto_now_add=True
         auto_now_add=True
     )
     )
     last_updated = models.DateTimeField(
     last_updated = models.DateTimeField(
+        verbose_name=_('last updated'),
         editable=False
         editable=False
     )
     )
     source = models.ForeignKey(
     source = models.ForeignKey(
@@ -251,20 +260,23 @@ class DataFile(models.Model):
         editable=False
         editable=False
     )
     )
     path = models.CharField(
     path = models.CharField(
+        verbose_name=_('path'),
         max_length=1000,
         max_length=1000,
         editable=False,
         editable=False,
         help_text=_("File path relative to the data source's root")
         help_text=_("File path relative to the data source's root")
     )
     )
     size = models.PositiveIntegerField(
     size = models.PositiveIntegerField(
-        editable=False
+        editable=False,
+        verbose_name=_('size')
     )
     )
     hash = models.CharField(
     hash = models.CharField(
+        verbose_name=_('hash'),
         max_length=64,
         max_length=64,
         editable=False,
         editable=False,
         validators=[
         validators=[
             RegexValidator(regex='^[0-9a-f]{64}$', message=_("Length must be 64 hexadecimal characters."))
             RegexValidator(regex='^[0-9a-f]{64}$', message=_("Length must be 64 hexadecimal characters."))
         ],
         ],
-        help_text=_("SHA256 hash of the file data")
+        help_text=_('SHA256 hash of the file data')
     )
     )
     data = models.BinaryField()
     data = models.BinaryField()
 
 

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

@@ -23,20 +23,24 @@ class ManagedFile(SyncedDataMixin, models.Model):
     to provide additional functionality.
     to provide additional functionality.
     """
     """
     created = models.DateTimeField(
     created = models.DateTimeField(
+        verbose_name=_('created'),
         auto_now_add=True
         auto_now_add=True
     )
     )
     last_updated = models.DateTimeField(
     last_updated = models.DateTimeField(
+        verbose_name=_('last updated'),
         editable=False,
         editable=False,
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
     file_root = models.CharField(
     file_root = models.CharField(
+        verbose_name=_('file root'),
         max_length=1000,
         max_length=1000,
         choices=ManagedFileRootPathChoices
         choices=ManagedFileRootPathChoices
     )
     )
     file_path = models.FilePathField(
     file_path = models.FilePathField(
+        verbose_name=_('file path'),
         editable=False,
         editable=False,
-        help_text=_("File path relative to the designated root path")
+        help_text=_('File path relative to the designated root path')
     )
     )
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()

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

@@ -43,28 +43,34 @@ class Job(models.Model):
         for_concrete_model=False
         for_concrete_model=False
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=200
         max_length=200
     )
     )
     created = models.DateTimeField(
     created = models.DateTimeField(
+        verbose_name=_('created'),
         auto_now_add=True
         auto_now_add=True
     )
     )
     scheduled = models.DateTimeField(
     scheduled = models.DateTimeField(
+        verbose_name=_('scheduled'),
         null=True,
         null=True,
         blank=True
         blank=True
     )
     )
     interval = models.PositiveIntegerField(
     interval = models.PositiveIntegerField(
+        verbose_name=_('interval'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=(
         validators=(
             MinValueValidator(1),
             MinValueValidator(1),
         ),
         ),
-        help_text=_("Recurrence interval (in minutes)")
+        help_text=_('Recurrence interval (in minutes)')
     )
     )
     started = models.DateTimeField(
     started = models.DateTimeField(
+        verbose_name=_('started'),
         null=True,
         null=True,
         blank=True
         blank=True
     )
     )
     completed = models.DateTimeField(
     completed = models.DateTimeField(
+        verbose_name=_('completed'),
         null=True,
         null=True,
         blank=True
         blank=True
     )
     )
@@ -76,15 +82,18 @@ class Job(models.Model):
         null=True
         null=True
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=30,
         max_length=30,
         choices=JobStatusChoices,
         choices=JobStatusChoices,
         default=JobStatusChoices.STATUS_PENDING
         default=JobStatusChoices.STATUS_PENDING
     )
     )
     data = models.JSONField(
     data = models.JSONField(
+        verbose_name=_('data'),
         null=True,
         null=True,
         blank=True
         blank=True
     )
     )
     job_id = models.UUIDField(
     job_id = models.UUIDField(
+        verbose_name=_('job ID'),
         unique=True
         unique=True
     )
     )
 
 

+ 12 - 1
netbox/dcim/models/cables.py

@@ -8,6 +8,7 @@ from django.db import models
 from django.db.models import Sum
 from django.db.models import Sum
 from django.dispatch import Signal
 from django.dispatch import Signal
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
@@ -40,11 +41,13 @@ class Cable(PrimaryModel):
     A physical connection between two endpoints.
     A physical connection between two endpoints.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=CableTypeChoices,
         choices=CableTypeChoices,
         blank=True
         blank=True
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=LinkStatusChoices,
         choices=LinkStatusChoices,
         default=LinkStatusChoices.STATUS_CONNECTED
         default=LinkStatusChoices.STATUS_CONNECTED
@@ -57,19 +60,23 @@ class Cable(PrimaryModel):
         null=True
         null=True
     )
     )
     label = models.CharField(
     label = models.CharField(
+        verbose_name=_('label'),
         max_length=100,
         max_length=100,
         blank=True
         blank=True
     )
     )
     color = ColorField(
     color = ColorField(
+        verbose_name=_('color'),
         blank=True
         blank=True
     )
     )
     length = models.DecimalField(
     length = models.DecimalField(
+        verbose_name=_('length'),
         max_digits=8,
         max_digits=8,
         decimal_places=2,
         decimal_places=2,
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
     length_unit = models.CharField(
     length_unit = models.CharField(
+        verbose_name=_('length unit'),
         max_length=50,
         max_length=50,
         choices=CableLengthUnitChoices,
         choices=CableLengthUnitChoices,
         blank=True,
         blank=True,
@@ -235,7 +242,7 @@ class CableTermination(ChangeLoggedModel):
     cable_end = models.CharField(
     cable_end = models.CharField(
         max_length=1,
         max_length=1,
         choices=CableEndChoices,
         choices=CableEndChoices,
-        verbose_name='End'
+        verbose_name=_('end')
     )
     )
     termination_type = models.ForeignKey(
     termination_type = models.ForeignKey(
         to=ContentType,
         to=ContentType,
@@ -403,15 +410,19 @@ class CablePath(models.Model):
     `_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
     `_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
     """
     """
     path = models.JSONField(
     path = models.JSONField(
+        verbose_name=_('path'),
         default=list
         default=list
     )
     )
     is_active = models.BooleanField(
     is_active = models.BooleanField(
+        verbose_name=_('is active'),
         default=False
         default=False
     )
     )
     is_complete = models.BooleanField(
     is_complete = models.BooleanField(
+        verbose_name=_('is complete'),
         default=False
         default=False
     )
     )
     is_split = models.BooleanField(
     is_split = models.BooleanField(
+        verbose_name=_('is split'),
         default=False
         default=False
     )
     )
     _nodes = PathField()
     _nodes = PathField()

+ 45 - 26
netbox/dcim/models/device_component_templates.py

@@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 
 
 from dcim.choices import *
 from dcim.choices import *
@@ -41,10 +41,11 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
         related_name='%(class)ss'
         related_name='%(class)ss'
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=64,
         max_length=64,
-        help_text="""
-        {module} is accepted as a substitution for the module bay position when attached to a module type.
-        """
+        help_text=_(
+            "{module} is accepted as a substitution for the module bay position when attached to a module type."
+        )
     )
     )
     _name = NaturalOrderingField(
     _name = NaturalOrderingField(
         target_field='name',
         target_field='name',
@@ -52,11 +53,13 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
         blank=True
         blank=True
     )
     )
     label = models.CharField(
     label = models.CharField(
+        verbose_name=_('label'),
         max_length=64,
         max_length=64,
         blank=True,
         blank=True,
-        help_text=_("Physical label")
+        help_text=_('Physical label')
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
@@ -98,7 +101,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
 
 
         if self.pk is not None and self._original_device_type != self.device_type_id:
         if self.pk is not None and self._original_device_type != self.device_type_id:
             raise ValidationError({
             raise ValidationError({
-                "device_type": "Component templates cannot be moved to a different device type."
+                "device_type": _("Component templates cannot be moved to a different device type.")
             })
             })
 
 
 
 
@@ -149,11 +152,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
         # A component template must belong to a DeviceType *or* to a ModuleType
         # A component template must belong to a DeviceType *or* to a ModuleType
         if self.device_type and self.module_type:
         if self.device_type and self.module_type:
             raise ValidationError(
             raise ValidationError(
-                "A component template cannot be associated with both a device type and a module type."
+                _("A component template cannot be associated with both a device type and a module type.")
             )
             )
         if not self.device_type and not self.module_type:
         if not self.device_type and not self.module_type:
             raise ValidationError(
             raise ValidationError(
-                "A component template must be associated with either a device type or a module type."
+                _("A component template must be associated with either a device type or a module type.")
             )
             )
 
 
     def resolve_name(self, module):
     def resolve_name(self, module):
@@ -172,6 +175,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
     A template for a ConsolePort to be created for a new Device.
     A template for a ConsolePort to be created for a new Device.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         blank=True
         blank=True
@@ -201,6 +205,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
     A template for a ConsoleServerPort to be created for a new Device.
     A template for a ConsoleServerPort to be created for a new Device.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         blank=True
         blank=True
@@ -231,21 +236,24 @@ class PowerPortTemplate(ModularComponentTemplateModel):
     A template for a PowerPort to be created for a new Device.
     A template for a PowerPort to be created for a new Device.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
         blank=True
         blank=True
     )
     )
     maximum_draw = models.PositiveIntegerField(
     maximum_draw = models.PositiveIntegerField(
+        verbose_name=_('maximum draw'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],
-        help_text=_("Maximum power draw (watts)")
+        help_text=_('Maximum power draw (watts)')
     )
     )
     allocated_draw = models.PositiveIntegerField(
     allocated_draw = models.PositiveIntegerField(
+        verbose_name=_('allocated draw'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],
-        help_text=_("Allocated power draw (watts)")
+        help_text=_('Allocated power draw (watts)')
     )
     )
 
 
     component_model = PowerPort
     component_model = PowerPort
@@ -267,7 +275,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
         if self.maximum_draw is not None and self.allocated_draw is not None:
         if self.maximum_draw is not None and self.allocated_draw is not None:
             if self.allocated_draw > self.maximum_draw:
             if self.allocated_draw > self.maximum_draw:
                 raise ValidationError({
                 raise ValidationError({
-                    'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
+                    'allocated_draw': _("Allocated draw cannot exceed the maximum draw ({maximum_draw}W).").format(maximum_draw=self.maximum_draw)
                 })
                 })
 
 
     def to_yaml(self):
     def to_yaml(self):
@@ -286,6 +294,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
     A template for a PowerOutlet to be created for a new Device.
     A template for a PowerOutlet to be created for a new Device.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
         blank=True
         blank=True
@@ -298,10 +307,11 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
         related_name='poweroutlet_templates'
         related_name='poweroutlet_templates'
     )
     )
     feed_leg = models.CharField(
     feed_leg = models.CharField(
+        verbose_name=_('feed leg'),
         max_length=50,
         max_length=50,
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
         blank=True,
         blank=True,
-        help_text=_("Phase (for three-phase feeds)")
+        help_text=_('Phase (for three-phase feeds)')
     )
     )
 
 
     component_model = PowerOutlet
     component_model = PowerOutlet
@@ -313,11 +323,11 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
         if self.power_port:
         if self.power_port:
             if self.device_type and self.power_port.device_type != self.device_type:
             if self.device_type and self.power_port.device_type != self.device_type:
                 raise ValidationError(
                 raise ValidationError(
-                    f"Parent power port ({self.power_port}) must belong to the same device type"
+                    _("Parent power port ({power_port}) must belong to the same device type").format(power_port=self.power_port)
                 )
                 )
             if self.module_type and self.power_port.module_type != self.module_type:
             if self.module_type and self.power_port.module_type != self.module_type:
                 raise ValidationError(
                 raise ValidationError(
-                    f"Parent power port ({self.power_port}) must belong to the same module type"
+                    _("Parent power port ({power_port}) must belong to the same module type").format(power_port=self.power_port)
                 )
                 )
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
@@ -359,15 +369,17 @@ class InterfaceTemplate(ModularComponentTemplateModel):
         blank=True
         blank=True
     )
     )
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=InterfaceTypeChoices
         choices=InterfaceTypeChoices
     )
     )
     enabled = models.BooleanField(
     enabled = models.BooleanField(
+        verbose_name=_('enabled'),
         default=True
         default=True
     )
     )
     mgmt_only = models.BooleanField(
     mgmt_only = models.BooleanField(
         default=False,
         default=False,
-        verbose_name='Management only'
+        verbose_name=_('management only')
     )
     )
     bridge = models.ForeignKey(
     bridge = models.ForeignKey(
         to='self',
         to='self',
@@ -375,25 +387,25 @@ class InterfaceTemplate(ModularComponentTemplateModel):
         related_name='bridge_interfaces',
         related_name='bridge_interfaces',
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='Bridge interface'
+        verbose_name=_('bridge interface')
     )
     )
     poe_mode = models.CharField(
     poe_mode = models.CharField(
         max_length=50,
         max_length=50,
         choices=InterfacePoEModeChoices,
         choices=InterfacePoEModeChoices,
         blank=True,
         blank=True,
-        verbose_name='PoE mode'
+        verbose_name=_('PoE mode')
     )
     )
     poe_type = models.CharField(
     poe_type = models.CharField(
         max_length=50,
         max_length=50,
         choices=InterfacePoETypeChoices,
         choices=InterfacePoETypeChoices,
         blank=True,
         blank=True,
-        verbose_name='PoE type'
+        verbose_name=_('PoE type')
     )
     )
     rf_role = models.CharField(
     rf_role = models.CharField(
         max_length=30,
         max_length=30,
         choices=WirelessRoleChoices,
         choices=WirelessRoleChoices,
         blank=True,
         blank=True,
-        verbose_name='Wireless role'
+        verbose_name=_('wireless role')
     )
     )
 
 
     component_model = Interface
     component_model = Interface
@@ -403,14 +415,14 @@ class InterfaceTemplate(ModularComponentTemplateModel):
 
 
         if self.bridge:
         if self.bridge:
             if self.pk and self.bridge_id == self.pk:
             if self.pk and self.bridge_id == self.pk:
-                raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
+                raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
             if self.device_type and self.device_type != self.bridge.device_type:
             if self.device_type and self.device_type != self.bridge.device_type:
                 raise ValidationError({
                 raise ValidationError({
-                    'bridge': f"Bridge interface ({self.bridge}) must belong to the same device type"
+                    'bridge': _("Bridge interface ({bridge}) must belong to the same device type").format(bridge=self.bridge)
                 })
                 })
             if self.module_type and self.module_type != self.bridge.module_type:
             if self.module_type and self.module_type != self.bridge.module_type:
                 raise ValidationError({
                 raise ValidationError({
-                    'bridge': f"Bridge interface ({self.bridge}) must belong to the same module type"
+                    'bridge': _("Bridge interface ({bridge}) must belong to the same module type").format(bridge=self.bridge)
                 })
                 })
 
 
         if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
         if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
@@ -452,10 +464,12 @@ class FrontPortTemplate(ModularComponentTemplateModel):
     Template for a pass-through port on the front of a new Device.
     Template for a pass-through port on the front of a new Device.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PortTypeChoices
         choices=PortTypeChoices
     )
     )
     color = ColorField(
     color = ColorField(
+        verbose_name=_('color'),
         blank=True
         blank=True
     )
     )
     rear_port = models.ForeignKey(
     rear_port = models.ForeignKey(
@@ -464,6 +478,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
         related_name='frontport_templates'
         related_name='frontport_templates'
     )
     )
     rear_port_position = models.PositiveSmallIntegerField(
     rear_port_position = models.PositiveSmallIntegerField(
+        verbose_name=_('rear port position'),
         default=1,
         default=1,
         validators=[
         validators=[
             MinValueValidator(REARPORT_POSITIONS_MIN),
             MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -497,13 +512,13 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             # Validate rear port assignment
             # Validate rear port assignment
             if self.rear_port.device_type != self.device_type:
             if self.rear_port.device_type != self.device_type:
                 raise ValidationError(
                 raise ValidationError(
-                    "Rear port ({}) must belong to the same device type".format(self.rear_port)
+                    _("Rear port ({}) must belong to the same device type").format(self.rear_port)
                 )
                 )
 
 
             # Validate rear port position assignment
             # Validate rear port position assignment
             if self.rear_port_position > self.rear_port.positions:
             if self.rear_port_position > self.rear_port.positions:
                 raise ValidationError(
                 raise ValidationError(
-                    "Invalid rear port position ({}); rear port {} has only {} positions".format(
+                    _("Invalid rear port position ({}); rear port {} has only {} positions").format(
                         self.rear_port_position, self.rear_port.name, self.rear_port.positions
                         self.rear_port_position, self.rear_port.name, self.rear_port.positions
                     )
                     )
                 )
                 )
@@ -545,13 +560,16 @@ class RearPortTemplate(ModularComponentTemplateModel):
     Template for a pass-through port on the rear of a new Device.
     Template for a pass-through port on the rear of a new Device.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PortTypeChoices
         choices=PortTypeChoices
     )
     )
     color = ColorField(
     color = ColorField(
+        verbose_name=_('color'),
         blank=True
         blank=True
     )
     )
     positions = models.PositiveSmallIntegerField(
     positions = models.PositiveSmallIntegerField(
+        verbose_name=_('positions'),
         default=1,
         default=1,
         validators=[
         validators=[
             MinValueValidator(REARPORT_POSITIONS_MIN),
             MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -588,6 +606,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
     A template for a ModuleBay to be created for a new parent Device.
     A template for a ModuleBay to be created for a new parent Device.
     """
     """
     position = models.CharField(
     position = models.CharField(
+        verbose_name=_('position'),
         max_length=30,
         max_length=30,
         blank=True,
         blank=True,
         help_text=_('Identifier to reference when renaming installed components')
         help_text=_('Identifier to reference when renaming installed components')
@@ -630,7 +649,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
     def clean(self):
     def clean(self):
         if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
         if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
             raise ValidationError(
             raise ValidationError(
-                f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
+                _("Subdevice role of device type ({device_type}) must be set to \"parent\" to allow device bays.").format(device_type=self.device_type)
             )
             )
 
 
     def to_yaml(self):
     def to_yaml(self):
@@ -685,7 +704,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
     )
     )
     part_id = models.CharField(
     part_id = models.CharField(
         max_length=50,
         max_length=50,
-        verbose_name='Part ID',
+        verbose_name=_('part ID'),
         blank=True,
         blank=True,
         help_text=_('Manufacturer-assigned part identifier')
         help_text=_('Manufacturer-assigned part identifier')
     )
     )

+ 145 - 80
netbox/dcim/models/device_components.py

@@ -7,7 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.db.models import Sum
 from django.db.models import Sum
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 
 
 from dcim.choices import *
 from dcim.choices import *
@@ -52,6 +52,7 @@ class ComponentModel(NetBoxModel):
         related_name='%(class)ss'
         related_name='%(class)ss'
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=64
         max_length=64
     )
     )
     _name = NaturalOrderingField(
     _name = NaturalOrderingField(
@@ -60,11 +61,13 @@ class ComponentModel(NetBoxModel):
         blank=True
         blank=True
     )
     )
     label = models.CharField(
     label = models.CharField(
+        verbose_name=_('label'),
         max_length=64,
         max_length=64,
         blank=True,
         blank=True,
-        help_text=_("Physical label")
+        help_text=_('Physical label')
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
@@ -101,7 +104,7 @@ class ComponentModel(NetBoxModel):
         # Check list of Modules that allow device field to be changed
         # Check list of Modules that allow device field to be changed
         if (type(self) not in [InventoryItem]) and (self.pk is not None) and (self._original_device != self.device_id):
         if (type(self) not in [InventoryItem]) and (self.pk is not None) and (self._original_device != self.device_id):
             raise ValidationError({
             raise ValidationError({
-                "device": "Components cannot be moved to a different device."
+                "device": _("Components cannot be moved to a different device.")
             })
             })
 
 
     @property
     @property
@@ -140,13 +143,15 @@ class CabledObjectModel(models.Model):
         null=True
         null=True
     )
     )
     cable_end = models.CharField(
     cable_end = models.CharField(
+        verbose_name=_('cable end'),
         max_length=1,
         max_length=1,
         blank=True,
         blank=True,
         choices=CableEndChoices
         choices=CableEndChoices
     )
     )
     mark_connected = models.BooleanField(
     mark_connected = models.BooleanField(
+        verbose_name=_('mark connected'),
         default=False,
         default=False,
-        help_text=_("Treat as if a cable is connected")
+        help_text=_('Treat as if a cable is connected')
     )
     )
 
 
     cable_terminations = GenericRelation(
     cable_terminations = GenericRelation(
@@ -164,15 +169,15 @@ class CabledObjectModel(models.Model):
 
 
         if self.cable and not self.cable_end:
         if self.cable and not self.cable_end:
             raise ValidationError({
             raise ValidationError({
-                "cable_end": "Must specify cable end (A or B) when attaching a cable."
+                "cable_end": _("Must specify cable end (A or B) when attaching a cable.")
             })
             })
         if self.cable_end and not self.cable:
         if self.cable_end and not self.cable:
             raise ValidationError({
             raise ValidationError({
-                "cable_end": "Cable end must not be set without a cable."
+                "cable_end": _("Cable end must not be set without a cable.")
             })
             })
         if self.mark_connected and self.cable:
         if self.mark_connected and self.cable:
             raise ValidationError({
             raise ValidationError({
-                "mark_connected": "Cannot mark as connected with a cable attached."
+                "mark_connected": _("Cannot mark as connected with a cable attached.")
             })
             })
 
 
     @property
     @property
@@ -195,7 +200,9 @@ class CabledObjectModel(models.Model):
 
 
     @property
     @property
     def parent_object(self):
     def parent_object(self):
-        raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property")
+        raise NotImplementedError(
+            _("{class_name} models must declare a parent_object property").format(class_name=self.__class__.__name__)
+        )
 
 
     @property
     @property
     def opposite_cable_end(self):
     def opposite_cable_end(self):
@@ -275,12 +282,14 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         blank=True,
         blank=True,
         help_text=_('Physical port type')
         help_text=_('Physical port type')
     )
     )
     speed = models.PositiveIntegerField(
     speed = models.PositiveIntegerField(
+        verbose_name=_('speed'),
         choices=ConsolePortSpeedChoices,
         choices=ConsolePortSpeedChoices,
         blank=True,
         blank=True,
         null=True,
         null=True,
@@ -298,12 +307,14 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint,
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         blank=True,
         blank=True,
         help_text=_('Physical port type')
         help_text=_('Physical port type')
     )
     )
     speed = models.PositiveIntegerField(
     speed = models.PositiveIntegerField(
+        verbose_name=_('speed'),
         choices=ConsolePortSpeedChoices,
         choices=ConsolePortSpeedChoices,
         blank=True,
         blank=True,
         null=True,
         null=True,
@@ -325,22 +336,25 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracking
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
         blank=True,
         blank=True,
         help_text=_('Physical port type')
         help_text=_('Physical port type')
     )
     )
     maximum_draw = models.PositiveIntegerField(
     maximum_draw = models.PositiveIntegerField(
+        verbose_name=_('maximum draw'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],
         help_text=_("Maximum power draw (watts)")
         help_text=_("Maximum power draw (watts)")
     )
     )
     allocated_draw = models.PositiveIntegerField(
     allocated_draw = models.PositiveIntegerField(
+        verbose_name=_('allocated draw'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],
-        help_text=_("Allocated power draw (watts)")
+        help_text=_('Allocated power draw (watts)')
     )
     )
 
 
     clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
     clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
@@ -354,7 +368,9 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracking
         if self.maximum_draw is not None and self.allocated_draw is not None:
         if self.maximum_draw is not None and self.allocated_draw is not None:
             if self.allocated_draw > self.maximum_draw:
             if self.allocated_draw > self.maximum_draw:
                 raise ValidationError({
                 raise ValidationError({
-                    'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
+                    'allocated_draw': _(
+                        "Allocated draw cannot exceed the maximum draw ({maximum_draw}W)."
+                    ).format(maximum_draw=self.maximum_draw)
                 })
                 })
 
 
     def get_downstream_powerports(self, leg=None):
     def get_downstream_powerports(self, leg=None):
@@ -434,6 +450,7 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
         blank=True,
         blank=True,
@@ -447,10 +464,11 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
         related_name='poweroutlets'
         related_name='poweroutlets'
     )
     )
     feed_leg = models.CharField(
     feed_leg = models.CharField(
+        verbose_name=_('feed leg'),
         max_length=50,
         max_length=50,
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
         blank=True,
         blank=True,
-        help_text=_("Phase (for three-phase feeds)")
+        help_text=_('Phase (for three-phase feeds)')
     )
     )
 
 
     clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
     clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
@@ -463,7 +481,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
 
 
         # Validate power port assignment
         # Validate power port assignment
         if self.power_port and self.power_port.device != self.device:
         if self.power_port and self.power_port.device != self.device:
-            raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device")
+            raise ValidationError(
+                _("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port)
+            )
 
 
 
 
 #
 #
@@ -475,12 +495,13 @@ class BaseInterface(models.Model):
     Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
     Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
     """
     """
     enabled = models.BooleanField(
     enabled = models.BooleanField(
+        verbose_name=_('enabled'),
         default=True
         default=True
     )
     )
     mac_address = MACAddressField(
     mac_address = MACAddressField(
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='MAC Address'
+        verbose_name=_('MAC address')
     )
     )
     mtu = models.PositiveIntegerField(
     mtu = models.PositiveIntegerField(
         blank=True,
         blank=True,
@@ -489,13 +510,14 @@ class BaseInterface(models.Model):
             MinValueValidator(INTERFACE_MTU_MIN),
             MinValueValidator(INTERFACE_MTU_MIN),
             MaxValueValidator(INTERFACE_MTU_MAX)
             MaxValueValidator(INTERFACE_MTU_MAX)
         ],
         ],
-        verbose_name='MTU'
+        verbose_name=_('MTU')
     )
     )
     mode = models.CharField(
     mode = models.CharField(
+        verbose_name=_('mode'),
         max_length=50,
         max_length=50,
         choices=InterfaceModeChoices,
         choices=InterfaceModeChoices,
         blank=True,
         blank=True,
-        help_text=_("IEEE 802.1Q tagging strategy")
+        help_text=_('IEEE 802.1Q tagging strategy')
     )
     )
     parent = models.ForeignKey(
     parent = models.ForeignKey(
         to='self',
         to='self',
@@ -503,7 +525,7 @@ class BaseInterface(models.Model):
         related_name='child_interfaces',
         related_name='child_interfaces',
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='Parent interface'
+        verbose_name=_('parent interface')
     )
     )
     bridge = models.ForeignKey(
     bridge = models.ForeignKey(
         to='self',
         to='self',
@@ -511,7 +533,7 @@ class BaseInterface(models.Model):
         related_name='bridge_interfaces',
         related_name='bridge_interfaces',
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='Bridge interface'
+        verbose_name=_('bridge interface')
     )
     )
 
 
     class Meta:
     class Meta:
@@ -559,23 +581,25 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         related_name='member_interfaces',
         related_name='member_interfaces',
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='Parent LAG'
+        verbose_name=_('parent LAG')
     )
     )
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=InterfaceTypeChoices
         choices=InterfaceTypeChoices
     )
     )
     mgmt_only = models.BooleanField(
     mgmt_only = models.BooleanField(
         default=False,
         default=False,
-        verbose_name='Management only',
+        verbose_name=_('management only'),
         help_text=_('This interface is used only for out-of-band management')
         help_text=_('This interface is used only for out-of-band management')
     )
     )
     speed = models.PositiveIntegerField(
     speed = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Speed (Kbps)'
+        verbose_name=_('speed (Kbps)')
     )
     )
     duplex = models.CharField(
     duplex = models.CharField(
+        verbose_name=_('duplex'),
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         null=True,
         null=True,
@@ -584,27 +608,27 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
     wwn = WWNField(
     wwn = WWNField(
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='WWN',
+        verbose_name=_('WWN'),
         help_text=_('64-bit World Wide Name')
         help_text=_('64-bit World Wide Name')
     )
     )
     rf_role = models.CharField(
     rf_role = models.CharField(
         max_length=30,
         max_length=30,
         choices=WirelessRoleChoices,
         choices=WirelessRoleChoices,
         blank=True,
         blank=True,
-        verbose_name='Wireless role'
+        verbose_name=_('wireless role')
     )
     )
     rf_channel = models.CharField(
     rf_channel = models.CharField(
         max_length=50,
         max_length=50,
         choices=WirelessChannelChoices,
         choices=WirelessChannelChoices,
         blank=True,
         blank=True,
-        verbose_name='Wireless channel'
+        verbose_name=_('wireless channel')
     )
     )
     rf_channel_frequency = models.DecimalField(
     rf_channel_frequency = models.DecimalField(
         max_digits=7,
         max_digits=7,
         decimal_places=2,
         decimal_places=2,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Channel frequency (MHz)',
+        verbose_name=_('channel frequency (MHz)'),
         help_text=_("Populated by selected channel (if set)")
         help_text=_("Populated by selected channel (if set)")
     )
     )
     rf_channel_width = models.DecimalField(
     rf_channel_width = models.DecimalField(
@@ -612,26 +636,26 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         decimal_places=3,
         decimal_places=3,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Channel width (MHz)',
+        verbose_name=('channel width (MHz)'),
         help_text=_("Populated by selected channel (if set)")
         help_text=_("Populated by selected channel (if set)")
     )
     )
     tx_power = models.PositiveSmallIntegerField(
     tx_power = models.PositiveSmallIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=(MaxValueValidator(127),),
         validators=(MaxValueValidator(127),),
-        verbose_name='Transmit power (dBm)'
+        verbose_name=_('transmit power (dBm)')
     )
     )
     poe_mode = models.CharField(
     poe_mode = models.CharField(
         max_length=50,
         max_length=50,
         choices=InterfacePoEModeChoices,
         choices=InterfacePoEModeChoices,
         blank=True,
         blank=True,
-        verbose_name='PoE mode'
+        verbose_name=_('PoE mode')
     )
     )
     poe_type = models.CharField(
     poe_type = models.CharField(
         max_length=50,
         max_length=50,
         choices=InterfacePoETypeChoices,
         choices=InterfacePoETypeChoices,
         blank=True,
         blank=True,
-        verbose_name='PoE type'
+        verbose_name=_('PoE type')
     )
     )
     wireless_link = models.ForeignKey(
     wireless_link = models.ForeignKey(
         to='wireless.WirelessLink',
         to='wireless.WirelessLink',
@@ -644,7 +668,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         to='wireless.WirelessLAN',
         to='wireless.WirelessLAN',
         related_name='interfaces',
         related_name='interfaces',
         blank=True,
         blank=True,
-        verbose_name='Wireless LANs'
+        verbose_name=_('wireless LANs')
     )
     )
     untagged_vlan = models.ForeignKey(
     untagged_vlan = models.ForeignKey(
         to='ipam.VLAN',
         to='ipam.VLAN',
@@ -652,13 +676,13 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         related_name='interfaces_as_untagged',
         related_name='interfaces_as_untagged',
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='Untagged VLAN'
+        verbose_name=_('untagged VLAN')
     )
     )
     tagged_vlans = models.ManyToManyField(
     tagged_vlans = models.ManyToManyField(
         to='ipam.VLAN',
         to='ipam.VLAN',
         related_name='interfaces_as_tagged',
         related_name='interfaces_as_tagged',
         blank=True,
         blank=True,
-        verbose_name='Tagged VLANs'
+        verbose_name=_('tagged VLANs')
     )
     )
     vrf = models.ForeignKey(
     vrf = models.ForeignKey(
         to='ipam.VRF',
         to='ipam.VRF',
@@ -666,7 +690,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         related_name='interfaces',
         related_name='interfaces',
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='VRF'
+        verbose_name=_('VRF')
     )
     )
     ip_addresses = GenericRelation(
     ip_addresses = GenericRelation(
         to='ipam.IPAddress',
         to='ipam.IPAddress',
@@ -704,77 +728,98 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         # Virtual Interfaces cannot have a Cable attached
         # Virtual Interfaces cannot have a Cable attached
         if self.is_virtual and self.cable:
         if self.is_virtual and self.cable:
             raise ValidationError({
             raise ValidationError({
-                'type': f"{self.get_type_display()} interfaces cannot have a cable attached."
+                'type': _("{display_type} interfaces cannot have a cable attached.").format(
+                    display_type=self.get_type_display()
+                )
             })
             })
 
 
         # Virtual Interfaces cannot be marked as connected
         # Virtual Interfaces cannot be marked as connected
         if self.is_virtual and self.mark_connected:
         if self.is_virtual and self.mark_connected:
             raise ValidationError({
             raise ValidationError({
-                'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected."
+                'mark_connected': _("{display_type} interfaces cannot be marked as connected.".format(
+                    display_type=self.get_type_display())
+                )
             })
             })
 
 
         # Parent validation
         # Parent validation
 
 
         # An interface cannot be its own parent
         # An interface cannot be its own parent
         if self.pk and self.parent_id == self.pk:
         if self.pk and self.parent_id == self.pk:
-            raise ValidationError({'parent': "An interface cannot be its own parent."})
+            raise ValidationError({'parent': _("An interface cannot be its own parent.")})
 
 
         # A physical interface cannot have a parent interface
         # A physical interface cannot have a parent interface
         if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
         if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
-            raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."})
+            raise ValidationError({'parent': _("Only virtual interfaces may be assigned to a parent interface.")})
 
 
         # An interface's parent must belong to the same device or virtual chassis
         # An interface's parent must belong to the same device or virtual chassis
         if self.parent and self.parent.device != self.device:
         if self.parent and self.parent.device != self.device:
             if self.device.virtual_chassis is None:
             if self.device.virtual_chassis is None:
                 raise ValidationError({
                 raise ValidationError({
-                    'parent': f"The selected parent interface ({self.parent}) belongs to a different device "
-                              f"({self.parent.device})."
+                    'parent': _(
+                        "The selected parent interface ({interface}) belongs to a different device ({device})"
+                    ).format(interface=self.parent, device=self.parent.device)
                 })
                 })
             elif self.parent.device.virtual_chassis != self.parent.virtual_chassis:
             elif self.parent.device.virtual_chassis != self.parent.virtual_chassis:
                 raise ValidationError({
                 raise ValidationError({
-                    'parent': f"The selected parent interface ({self.parent}) belongs to {self.parent.device}, which "
-                              f"is not part of virtual chassis {self.device.virtual_chassis}."
+                    'parent': _(
+                        "The selected parent interface ({interface}) belongs to {device}, which is not part of "
+                        "virtual chassis {virtual_chassis}."
+                    ).format(
+                        interface=self.parent,
+                        device=self.parent_device,
+                        virtual_chassis=self.device.virtual_chassis
+                    )
                 })
                 })
 
 
         # Bridge validation
         # Bridge validation
 
 
         # An interface cannot be bridged to itself
         # An interface cannot be bridged to itself
         if self.pk and self.bridge_id == self.pk:
         if self.pk and self.bridge_id == self.pk:
-            raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
+            raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
 
 
         # A bridged interface belong to the same device or virtual chassis
         # A bridged interface belong to the same device or virtual chassis
         if self.bridge and self.bridge.device != self.device:
         if self.bridge and self.bridge.device != self.device:
             if self.device.virtual_chassis is None:
             if self.device.virtual_chassis is None:
                 raise ValidationError({
                 raise ValidationError({
-                    'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different device "
-                              f"({self.bridge.device})."
+                    'bridge': _("""
+                    The selected bridge interface ({bridge}) belongs to a different device
+                    ({device}).""").format(bridge=self.bridge, device=self.bridge.device)
                 })
                 })
             elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
             elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
                 raise ValidationError({
                 raise ValidationError({
-                    'bridge': f"The selected bridge interface ({self.bridge}) belongs to {self.bridge.device}, which "
-                              f"is not part of virtual chassis {self.device.virtual_chassis}."
+                    'bridge': _(
+                        "The selected bridge interface ({interface}) belongs to {device}, which is not part of virtual "
+                        "chassis {virtual_chassis}."
+                    ).format(
+                        interface=self.bridge, device=self.bridge.device, virtual_chassis=self.device.virtual_chassis
+                    )
                 })
                 })
 
 
         # LAG validation
         # LAG validation
 
 
         # A virtual interface cannot have a parent LAG
         # A virtual interface cannot have a parent LAG
         if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
         if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
-            raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
+            raise ValidationError({'lag': _("Virtual interfaces cannot have a parent LAG interface.")})
 
 
         # A LAG interface cannot be its own parent
         # A LAG interface cannot be its own parent
         if self.pk and self.lag_id == self.pk:
         if self.pk and self.lag_id == self.pk:
-            raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
+            raise ValidationError({'lag': _("A LAG interface cannot be its own parent.")})
 
 
         # An interface's LAG must belong to the same device or virtual chassis
         # An interface's LAG must belong to the same device or virtual chassis
         if self.lag and self.lag.device != self.device:
         if self.lag and self.lag.device != self.device:
             if self.device.virtual_chassis is None:
             if self.device.virtual_chassis is None:
                 raise ValidationError({
                 raise ValidationError({
-                    'lag': f"The selected LAG interface ({self.lag}) belongs to a different device ({self.lag.device})."
+                    'lag': _(
+                        "The selected LAG interface ({lag}) belongs to a different device ({device})."
+                    ).format(lag=self.lag, device=self.lag.device)
                 })
                 })
             elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
             elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
                 raise ValidationError({
                 raise ValidationError({
-                    'lag': f"The selected LAG interface ({self.lag}) belongs to {self.lag.device}, which is not part "
-                           f"of virtual chassis {self.device.virtual_chassis}."
+                    'lag': _(
+                        "The selected LAG interface ({lag}) belongs to {device}, which is not part of virtual chassis "
+                        "{virtual_chassis}.".format(
+                            lag=self.lag, device=self.lag.device, virtual_chassis=self.device.virtual_chassis)
+                    )
                 })
                 })
 
 
         # PoE validation
         # PoE validation
@@ -782,52 +827,54 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         # Only physical interfaces may have a PoE mode/type assigned
         # Only physical interfaces may have a PoE mode/type assigned
         if self.poe_mode and self.is_virtual:
         if self.poe_mode and self.is_virtual:
             raise ValidationError({
             raise ValidationError({
-                'poe_mode': "Virtual interfaces cannot have a PoE mode."
+                'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
             })
             })
         if self.poe_type and self.is_virtual:
         if self.poe_type and self.is_virtual:
             raise ValidationError({
             raise ValidationError({
-                'poe_type': "Virtual interfaces cannot have a PoE type."
+                'poe_type': _("Virtual interfaces cannot have a PoE type.")
             })
             })
 
 
         # An interface with a PoE type set must also specify a mode
         # An interface with a PoE type set must also specify a mode
         if self.poe_type and not self.poe_mode:
         if self.poe_type and not self.poe_mode:
             raise ValidationError({
             raise ValidationError({
-                'poe_type': "Must specify PoE mode when designating a PoE type."
+                'poe_type': _("Must specify PoE mode when designating a PoE type.")
             })
             })
 
 
         # Wireless validation
         # Wireless validation
 
 
         # RF role & channel may only be set for wireless interfaces
         # RF role & channel may only be set for wireless interfaces
         if self.rf_role and not self.is_wireless:
         if self.rf_role and not self.is_wireless:
-            raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."})
+            raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
         if self.rf_channel and not self.is_wireless:
         if self.rf_channel and not self.is_wireless:
-            raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."})
+            raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})
 
 
         # Validate channel frequency against interface type and selected channel (if any)
         # Validate channel frequency against interface type and selected channel (if any)
         if self.rf_channel_frequency:
         if self.rf_channel_frequency:
             if not self.is_wireless:
             if not self.is_wireless:
                 raise ValidationError({
                 raise ValidationError({
-                    'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.",
+                    'rf_channel_frequency': _("Channel frequency may be set only on wireless interfaces."),
                 })
                 })
             if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
             if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
                 raise ValidationError({
                 raise ValidationError({
-                    'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
+                    'rf_channel_frequency': _("Cannot specify custom frequency with channel selected."),
                 })
                 })
 
 
         # Validate channel width against interface type and selected channel (if any)
         # Validate channel width against interface type and selected channel (if any)
         if self.rf_channel_width:
         if self.rf_channel_width:
             if not self.is_wireless:
             if not self.is_wireless:
-                raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
+                raise ValidationError({'rf_channel_width': _("Channel width may be set only on wireless interfaces.")})
             if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
             if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
-                raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
+                raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
 
 
         # VLAN validation
         # VLAN validation
 
 
         # Validate untagged VLAN
         # Validate untagged VLAN
         if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
         if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
             raise ValidationError({
             raise ValidationError({
-                'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
-                                 f"interface's parent device, or it must be global."
+                'untagged_vlan': _("""
+                    The untagged VLAN ({untagged_vlan}) must belong to the same site as the
+                    interface's parent device, or it must be global.
+                    """).format(untagged_vlan=self.untagged_vlan)
             })
             })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -894,10 +941,12 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
     A pass-through port on the front of a Device.
     A pass-through port on the front of a Device.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PortTypeChoices
         choices=PortTypeChoices
     )
     )
     color = ColorField(
     color = ColorField(
+        verbose_name=_('color'),
         blank=True
         blank=True
     )
     )
     rear_port = models.ForeignKey(
     rear_port = models.ForeignKey(
@@ -906,6 +955,7 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
         related_name='frontports'
         related_name='frontports'
     )
     )
     rear_port_position = models.PositiveSmallIntegerField(
     rear_port_position = models.PositiveSmallIntegerField(
+        verbose_name=_('rear port position'),
         default=1,
         default=1,
         validators=[
         validators=[
             MinValueValidator(REARPORT_POSITIONS_MIN),
             MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -939,14 +989,22 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
             # Validate rear port assignment
             # Validate rear port assignment
             if self.rear_port.device != self.device:
             if self.rear_port.device != self.device:
                 raise ValidationError({
                 raise ValidationError({
-                    "rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
+                    "rear_port": _(
+                        "Rear port ({rear_port}) must belong to the same device"
+                    ).format(rear_port=self.rear_port)
                 })
                 })
 
 
             # Validate rear port position assignment
             # Validate rear port position assignment
             if self.rear_port_position > self.rear_port.positions:
             if self.rear_port_position > self.rear_port.positions:
                 raise ValidationError({
                 raise ValidationError({
-                    "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
-                                          f"{self.rear_port.name} has only {self.rear_port.positions} positions"
+                    "rear_port_position": _(
+                        "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
+                        "positions."
+                    ).format(
+                        rear_port_position=self.rear_port_position,
+                        name=self.rear_port.name,
+                        positions=self.rear_port.positions
+                    )
                 })
                 })
 
 
 
 
@@ -955,13 +1013,16 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
     A pass-through port on the rear of a Device.
     A pass-through port on the rear of a Device.
     """
     """
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PortTypeChoices
         choices=PortTypeChoices
     )
     )
     color = ColorField(
     color = ColorField(
+        verbose_name=_('color'),
         blank=True
         blank=True
     )
     )
     positions = models.PositiveSmallIntegerField(
     positions = models.PositiveSmallIntegerField(
+        verbose_name=_('positions'),
         default=1,
         default=1,
         validators=[
         validators=[
             MinValueValidator(REARPORT_POSITIONS_MIN),
             MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -982,8 +1043,9 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
             frontport_count = self.frontports.count()
             frontport_count = self.frontports.count()
             if self.positions < frontport_count:
             if self.positions < frontport_count:
                 raise ValidationError({
                 raise ValidationError({
-                    "positions": f"The number of positions cannot be less than the number of mapped front ports "
-                                 f"({frontport_count})"
+                    "positions": _("""
+                        The number of positions cannot be less than the number of mapped front ports
+                        ({frontport_count})""").format(frontport_count=frontport_count)
                 })
                 })
 
 
 
 
@@ -996,6 +1058,7 @@ class ModuleBay(ComponentModel, TrackingModelMixin):
     An empty space within a Device which can house a child device
     An empty space within a Device which can house a child device
     """
     """
     position = models.CharField(
     position = models.CharField(
+        verbose_name=_('position'),
         max_length=30,
         max_length=30,
         blank=True,
         blank=True,
         help_text=_('Identifier to reference when renaming installed components')
         help_text=_('Identifier to reference when renaming installed components')
@@ -1014,7 +1077,7 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
     installed_device = models.OneToOneField(
     installed_device = models.OneToOneField(
         to='dcim.Device',
         to='dcim.Device',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
-        related_name='parent_bay',
+        related_name=_('parent_bay'),
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
@@ -1029,22 +1092,22 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
 
 
         # Validate that the parent Device can have DeviceBays
         # Validate that the parent Device can have DeviceBays
         if not self.device.device_type.is_parent_device:
         if not self.device.device_type.is_parent_device:
-            raise ValidationError("This type of device ({}) does not support device bays.".format(
-                self.device.device_type
+            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
         # Cannot install a device into itself, obviously
         if self.device == self.installed_device:
         if self.device == self.installed_device:
-            raise ValidationError("Cannot install a device into itself.")
+            raise ValidationError(_("Cannot install a device into itself."))
 
 
         # Check that the installed device is not already installed elsewhere
         # Check that the installed device is not already installed elsewhere
         if self.installed_device:
         if self.installed_device:
             current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
             current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
             if current_bay and current_bay != self:
             if current_bay and current_bay != self:
                 raise ValidationError({
                 raise ValidationError({
-                    'installed_device': "Cannot install the specified device; device is already installed in {}".format(
-                        current_bay
-                    )
+                    'installed_device': _(
+                        "Cannot install the specified device; device is already installed in {bay}."
+                    ).format(bay=current_bay)
                 })
                 })
 
 
 
 
@@ -1058,6 +1121,7 @@ class InventoryItemRole(OrganizationalModel):
     Inventory items may optionally be assigned a functional role.
     Inventory items may optionally be assigned a functional role.
     """
     """
     color = ColorField(
     color = ColorField(
+        verbose_name=_('color'),
         default=ColorChoices.COLOR_GREY
         default=ColorChoices.COLOR_GREY
     )
     )
 
 
@@ -1110,13 +1174,13 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
     )
     )
     part_id = models.CharField(
     part_id = models.CharField(
         max_length=50,
         max_length=50,
-        verbose_name='Part ID',
+        verbose_name=_('part ID'),
         blank=True,
         blank=True,
         help_text=_('Manufacturer-assigned part identifier')
         help_text=_('Manufacturer-assigned part identifier')
     )
     )
     serial = models.CharField(
     serial = models.CharField(
         max_length=50,
         max_length=50,
-        verbose_name='Serial number',
+        verbose_name=_('serial number'),
         blank=True
         blank=True
     )
     )
     asset_tag = models.CharField(
     asset_tag = models.CharField(
@@ -1124,10 +1188,11 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
         unique=True,
         unique=True,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Asset tag',
+        verbose_name=_('asset tag'),
         help_text=_('A unique tag used to identify this item')
         help_text=_('A unique tag used to identify this item')
     )
     )
     discovered = models.BooleanField(
     discovered = models.BooleanField(
+        verbose_name=_('discovered'),
         default=False,
         default=False,
         help_text=_('This item was automatically discovered')
         help_text=_('This item was automatically discovered')
     )
     )
@@ -1154,7 +1219,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
         # An InventoryItem cannot be its own parent
         # An InventoryItem cannot be its own parent
         if self.pk and self.parent_id == self.pk:
         if self.pk and self.parent_id == self.pk:
             raise ValidationError({
             raise ValidationError({
-                "parent": "Cannot assign self as parent."
+                "parent": _("Cannot assign self as parent.")
             })
             })
 
 
         # Validation for moving InventoryItems
         # Validation for moving InventoryItems
@@ -1162,13 +1227,13 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
             # Cannot move an InventoryItem to another device if it has a parent
             # Cannot move an InventoryItem to another device if it has a parent
             if self.parent and self.parent.device != self.device:
             if self.parent and self.parent.device != self.device:
                 raise ValidationError({
                 raise ValidationError({
-                    "parent": "Parent inventory item does not belong to the same device."
+                    "parent": _("Parent inventory item does not belong to the same device.")
                 })
                 })
 
 
             # Prevent moving InventoryItems with children
             # Prevent moving InventoryItems with children
             first_child = self.get_children().first()
             first_child = self.get_children().first()
             if first_child and first_child.device != self.device:
             if first_child and first_child.device != self.device:
-                raise ValidationError("Cannot move an inventory item with dependent children")
+                raise ValidationError(_("Cannot move an inventory item with dependent children"))
 
 
             # When moving an InventoryItem to another device, remove any associated component
             # When moving an InventoryItem to another device, remove any associated component
             if self.component and self.component.device != self.device:
             if self.component and self.component.device != self.device:
@@ -1176,5 +1241,5 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
         else:
         else:
             if self.component and self.component.device != self.device:
             if self.component and self.component.device != self.device:
                 raise ValidationError({
                 raise ValidationError({
-                    "device": "Cannot assign inventory item to component on another device"
+                    "device": _("Cannot assign inventory item to component on another device")
                 })
                 })

+ 110 - 59
netbox/dcim/models/devices.py

@@ -12,7 +12,7 @@ from django.db.models.functions import Lower
 from django.db.models.signals import post_save
 from django.db.models.signals import post_save
 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.utils.translation import gettext_lazy as _
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
@@ -78,9 +78,11 @@ class DeviceType(PrimaryModel, WeightMixin):
         related_name='device_types'
         related_name='device_types'
     )
     )
     model = models.CharField(
     model = models.CharField(
+        verbose_name=_('model'),
         max_length=100
         max_length=100
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100
         max_length=100
     )
     )
     default_platform = models.ForeignKey(
     default_platform = models.ForeignKey(
@@ -89,9 +91,10 @@ class DeviceType(PrimaryModel, WeightMixin):
         related_name='+',
         related_name='+',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Default platform'
+        verbose_name=_('default platform')
     )
     )
     part_number = models.CharField(
     part_number = models.CharField(
+        verbose_name=_('part number'),
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         help_text=_('Discrete part number (optional)')
         help_text=_('Discrete part number (optional)')
@@ -100,22 +103,23 @@ class DeviceType(PrimaryModel, WeightMixin):
         max_digits=4,
         max_digits=4,
         decimal_places=1,
         decimal_places=1,
         default=1.0,
         default=1.0,
-        verbose_name='Height (U)'
+        verbose_name=_('height (U)')
     )
     )
     is_full_depth = models.BooleanField(
     is_full_depth = models.BooleanField(
         default=True,
         default=True,
-        verbose_name='Is full depth',
+        verbose_name=_('is full depth'),
         help_text=_('Device consumes both front and rear rack faces')
         help_text=_('Device consumes both front and rear rack faces')
     )
     )
     subdevice_role = models.CharField(
     subdevice_role = models.CharField(
         max_length=50,
         max_length=50,
         choices=SubdeviceRoleChoices,
         choices=SubdeviceRoleChoices,
         blank=True,
         blank=True,
-        verbose_name='Parent/child status',
+        verbose_name=_('parent/child status'),
         help_text=_('Parent devices house child devices in device bays. Leave blank '
         help_text=_('Parent devices house child devices in device bays. Leave blank '
                     'if this device type is neither a parent nor a child.')
                     'if this device type is neither a parent nor a child.')
     )
     )
     airflow = models.CharField(
     airflow = models.CharField(
+        verbose_name=_('airflow'),
         max_length=50,
         max_length=50,
         choices=DeviceAirflowChoices,
         choices=DeviceAirflowChoices,
         blank=True
         blank=True
@@ -176,7 +180,8 @@ class DeviceType(PrimaryModel, WeightMixin):
     )
     )
 
 
     clone_fields = (
     clone_fields = (
-        'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
+        'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
+        'weight_unit',
     )
     )
     prerequisite_models = (
     prerequisite_models = (
         'dcim.Manufacturer',
         'dcim.Manufacturer',
@@ -277,7 +282,7 @@ class DeviceType(PrimaryModel, WeightMixin):
         # U height must be divisible by 0.5
         # U height must be divisible by 0.5
         if decimal.Decimal(self.u_height) % decimal.Decimal(0.5):
         if decimal.Decimal(self.u_height) % decimal.Decimal(0.5):
             raise ValidationError({
             raise ValidationError({
-                'u_height': "U height must be in increments of 0.5 rack units."
+                'u_height': _("U height must be in increments of 0.5 rack units.")
             })
             })
 
 
         # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
         # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
@@ -293,8 +298,8 @@ class DeviceType(PrimaryModel, WeightMixin):
                 )
                 )
                 if d.position not in u_available:
                 if d.position not in u_available:
                     raise ValidationError({
                     raise ValidationError({
-                        'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of "
-                                    "{}U".format(d, d.rack, self.u_height)
+                        'u_height': _("Device {} in rack {} does not have sufficient space to accommodate a height of "
+                                      "{}U").format(d, d.rack, self.u_height)
                     })
                     })
 
 
         # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
         # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
@@ -306,23 +311,23 @@ class DeviceType(PrimaryModel, WeightMixin):
             if racked_instance_count:
             if racked_instance_count:
                 url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
                 url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
                 raise ValidationError({
                 raise ValidationError({
-                    'u_height': mark_safe(
-                        f'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already '
-                        f'mounted within racks.'
-                    )
+                    'u_height': mark_safe(_(
+                        'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already '
+                        'mounted within racks.'
+                    ).format(url=url, racked_instance_count=racked_instance_count))
                 })
                 })
 
 
         if (
         if (
                 self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
                 self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
         ) and self.pk and self.devicebaytemplates.count():
         ) and self.pk and self.devicebaytemplates.count():
             raise ValidationError({
             raise ValidationError({
-                'subdevice_role': "Must delete all device bay templates associated with this device before "
-                                  "declassifying it as a parent device."
+                'subdevice_role': _("Must delete all device bay templates associated with this device before "
+                                  "declassifying it as a parent device.")
             })
             })
 
 
         if self.u_height and self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD:
         if self.u_height and self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD:
             raise ValidationError({
             raise ValidationError({
-                'u_height': "Child device types must be 0U."
+                'u_height': _("Child device types must be 0U.")
             })
             })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -367,9 +372,11 @@ class ModuleType(PrimaryModel, WeightMixin):
         related_name='module_types'
         related_name='module_types'
     )
     )
     model = models.CharField(
     model = models.CharField(
+        verbose_name=_('model'),
         max_length=100
         max_length=100
     )
     )
     part_number = models.CharField(
     part_number = models.CharField(
+        verbose_name=_('part number'),
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         help_text=_('Discrete part number (optional)')
         help_text=_('Discrete part number (optional)')
@@ -454,11 +461,12 @@ class DeviceRole(OrganizationalModel):
     virtual machines as well.
     virtual machines as well.
     """
     """
     color = ColorField(
     color = ColorField(
+        verbose_name=_('color'),
         default=ColorChoices.COLOR_GREY
         default=ColorChoices.COLOR_GREY
     )
     )
     vm_role = models.BooleanField(
     vm_role = models.BooleanField(
         default=True,
         default=True,
-        verbose_name='VM Role',
+        verbose_name=_('VM role'),
         help_text=_('Virtual machines may be assigned to this role')
         help_text=_('Virtual machines may be assigned to this role')
     )
     )
     config_template = models.ForeignKey(
     config_template = models.ForeignKey(
@@ -550,6 +558,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         null=True
         null=True
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=64,
         max_length=64,
         blank=True,
         blank=True,
         null=True
         null=True
@@ -563,7 +572,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
     serial = models.CharField(
     serial = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
-        verbose_name='Serial number',
+        verbose_name=_('serial number'),
         help_text=_("Chassis serial number, assigned by the manufacturer")
         help_text=_("Chassis serial number, assigned by the manufacturer")
     )
     )
     asset_tag = models.CharField(
     asset_tag = models.CharField(
@@ -571,7 +580,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         blank=True,
         blank=True,
         null=True,
         null=True,
         unique=True,
         unique=True,
-        verbose_name='Asset tag',
+        verbose_name=_('asset tag'),
         help_text=_('A unique tag used to identify this device')
         help_text=_('A unique tag used to identify this device')
     )
     )
     site = models.ForeignKey(
     site = models.ForeignKey(
@@ -599,21 +608,23 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)],
         validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)],
-        verbose_name='Position (U)',
+        verbose_name=_('position (U)'),
         help_text=_('The lowest-numbered unit occupied by the device')
         help_text=_('The lowest-numbered unit occupied by the device')
     )
     )
     face = models.CharField(
     face = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         choices=DeviceFaceChoices,
         choices=DeviceFaceChoices,
-        verbose_name='Rack face'
+        verbose_name=_('rack face')
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=DeviceStatusChoices,
         choices=DeviceStatusChoices,
         default=DeviceStatusChoices.STATUS_ACTIVE
         default=DeviceStatusChoices.STATUS_ACTIVE
     )
     )
     airflow = models.CharField(
     airflow = models.CharField(
+        verbose_name=_('airflow'),
         max_length=50,
         max_length=50,
         choices=DeviceAirflowChoices,
         choices=DeviceAirflowChoices,
         blank=True
         blank=True
@@ -624,7 +635,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         related_name='+',
         related_name='+',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Primary IPv4'
+        verbose_name=_('primary IPv4')
     )
     )
     primary_ip6 = models.OneToOneField(
     primary_ip6 = models.OneToOneField(
         to='ipam.IPAddress',
         to='ipam.IPAddress',
@@ -632,7 +643,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         related_name='+',
         related_name='+',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Primary IPv6'
+        verbose_name=_('primary IPv6')
     )
     )
     oob_ip = models.OneToOneField(
     oob_ip = models.OneToOneField(
         to='ipam.IPAddress',
         to='ipam.IPAddress',
@@ -640,7 +651,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         related_name='+',
         related_name='+',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Out-of-band IP'
+        verbose_name=_('out-of-band IP')
     )
     )
     cluster = models.ForeignKey(
     cluster = models.ForeignKey(
         to='virtualization.Cluster',
         to='virtualization.Cluster',
@@ -657,12 +668,14 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         null=True
         null=True
     )
     )
     vc_position = models.PositiveSmallIntegerField(
     vc_position = models.PositiveSmallIntegerField(
+        verbose_name=_('VC position'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MaxValueValidator(255)],
         validators=[MaxValueValidator(255)],
         help_text=_('Virtual chassis position')
         help_text=_('Virtual chassis position')
     )
     )
     vc_priority = models.PositiveSmallIntegerField(
     vc_priority = models.PositiveSmallIntegerField(
+        verbose_name=_('VC priority'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         validators=[MaxValueValidator(255)],
         validators=[MaxValueValidator(255)],
@@ -676,6 +689,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         null=True
         null=True
     )
     )
     latitude = models.DecimalField(
     latitude = models.DecimalField(
+        verbose_name=_('latitude'),
         max_digits=8,
         max_digits=8,
         decimal_places=6,
         decimal_places=6,
         blank=True,
         blank=True,
@@ -683,6 +697,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
         help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
     )
     )
     longitude = models.DecimalField(
     longitude = models.DecimalField(
+        verbose_name=_('longitude'),
         max_digits=9,
         max_digits=9,
         decimal_places=6,
         decimal_places=6,
         blank=True,
         blank=True,
@@ -763,7 +778,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
                 Lower('name'), 'site',
                 Lower('name'), 'site',
                 name='%(app_label)s_%(class)s_unique_name_site',
                 name='%(app_label)s_%(class)s_unique_name_site',
                 condition=Q(tenant__isnull=True),
                 condition=Q(tenant__isnull=True),
-                violation_error_message="Device name must be unique per site."
+                violation_error_message=_("Device name must be unique per site.")
             ),
             ),
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('rack', 'position', 'face'),
                 fields=('rack', 'position', 'face'),
@@ -799,42 +814,48 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         # Validate site/location/rack combination
         # Validate site/location/rack combination
         if self.rack and self.site != self.rack.site:
         if self.rack and self.site != self.rack.site:
             raise ValidationError({
             raise ValidationError({
-                'rack': f"Rack {self.rack} does not belong to site {self.site}.",
+                'rack': _("Rack {rack} does not belong to site {site}.").format(rack=self.rack, site=self.site),
             })
             })
         if self.location and self.site != self.location.site:
         if self.location and self.site != self.location.site:
             raise ValidationError({
             raise ValidationError({
-                'location': f"Location {self.location} does not belong to site {self.site}.",
+                'location': _(
+                    "Location {location} does not belong to site {site}."
+                ).format(location=self.location, site=self.site)
             })
             })
         if self.rack and self.location and self.rack.location != self.location:
         if self.rack and self.location and self.rack.location != self.location:
             raise ValidationError({
             raise ValidationError({
-                'rack': f"Rack {self.rack} does not belong to location {self.location}.",
+                'rack': _(
+                    "Rack {rack} does not belong to location {location}."
+                ).format(rack=self.rack, location=self.location)
             })
             })
 
 
         if self.rack is None:
         if self.rack is None:
             if self.face:
             if self.face:
                 raise ValidationError({
                 raise ValidationError({
-                    'face': "Cannot select a rack face without assigning a rack.",
+                    'face': _("Cannot select a rack face without assigning a rack."),
                 })
                 })
             if self.position:
             if self.position:
                 raise ValidationError({
                 raise ValidationError({
-                    'position': "Cannot select a rack position without assigning a rack.",
+                    'position': _("Cannot select a rack position without assigning a rack."),
                 })
                 })
 
 
         # Validate rack position and face
         # Validate rack position and face
         if self.position and self.position % decimal.Decimal(0.5):
         if self.position and self.position % decimal.Decimal(0.5):
             raise ValidationError({
             raise ValidationError({
-                'position': "Position must be in increments of 0.5 rack units."
+                'position': _("Position must be in increments of 0.5 rack units.")
             })
             })
         if self.position and not self.face:
         if self.position and not self.face:
             raise ValidationError({
             raise ValidationError({
-                'face': "Must specify rack face when defining rack position.",
+                'face': _("Must specify rack face when defining rack position."),
             })
             })
 
 
         # Prevent 0U devices from being assigned to a specific position
         # Prevent 0U devices from being assigned to a specific position
         if hasattr(self, 'device_type'):
         if hasattr(self, 'device_type'):
             if self.position and self.device_type.u_height == 0:
             if self.position and self.device_type.u_height == 0:
                 raise ValidationError({
                 raise ValidationError({
-                    'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
+                    'position': _(
+                        "A U0 device type ({device_type}) cannot be assigned to a rack position."
+                    ).format(device_type=self.device_type)
                 })
                 })
 
 
         if self.rack:
         if self.rack:
@@ -843,13 +864,17 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
                 # Child devices cannot be assigned to a rack face/unit
                 # Child devices cannot be assigned to a rack face/unit
                 if self.device_type.is_child_device and self.face:
                 if self.device_type.is_child_device and self.face:
                     raise ValidationError({
                     raise ValidationError({
-                        'face': "Child device types cannot be assigned to a rack face. This is an attribute of the "
-                                "parent device."
+                        'face': _(
+                            "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
+                            "device."
+                        )
                     })
                     })
                 if self.device_type.is_child_device and self.position:
                 if self.device_type.is_child_device and self.position:
                     raise ValidationError({
                     raise ValidationError({
-                        'position': "Child device types cannot be assigned to a rack position. This is an attribute of "
-                                    "the parent device."
+                        'position': _(
+                            "Child device types cannot be assigned to a rack position. This is an attribute of the "
+                            "parent device."
+                        )
                     })
                     })
 
 
                 # Validate rack space
                 # Validate rack space
@@ -860,8 +885,12 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
                 )
                 )
                 if self.position and self.position not in available_units:
                 if self.position and self.position not in available_units:
                     raise ValidationError({
                     raise ValidationError({
-                        'position': f"U{self.position} is already occupied or does not have sufficient space to "
-                                    f"accommodate this device type: {self.device_type} ({self.device_type.u_height}U)"
+                        'position': _(
+                            "U{position} is already occupied or does not have sufficient space to accommodate this "
+                            "device type: {device_type} ({u_height}U)"
+                        ).format(
+                            position=self.position, device_type=self.device_type, u_height=self.device_type.u_height
+                        )
                     })
                     })
 
 
             except DeviceType.DoesNotExist:
             except DeviceType.DoesNotExist:
@@ -872,7 +901,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         if self.primary_ip4:
         if self.primary_ip4:
             if self.primary_ip4.family != 4:
             if self.primary_ip4.family != 4:
                 raise ValidationError({
                 raise ValidationError({
-                    'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
+                    'primary_ip4': _("{primary_ip4} is not an IPv4 address.").format(primary_ip4=self.primary_ip4)
                 })
                 })
             if self.primary_ip4.assigned_object in vc_interfaces:
             if self.primary_ip4.assigned_object in vc_interfaces:
                 pass
                 pass
@@ -880,12 +909,14 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
                 pass
                 pass
             else:
             else:
                 raise ValidationError({
                 raise ValidationError({
-                    'primary_ip4': f"The specified IP address ({self.primary_ip4}) is not assigned to this device."
+                    'primary_ip4': _(
+                        "The specified IP address ({primary_ip4}) is not assigned to this device."
+                    ).format(primary_ip4=self.primary_ip4)
                 })
                 })
         if self.primary_ip6:
         if self.primary_ip6:
             if self.primary_ip6.family != 6:
             if self.primary_ip6.family != 6:
                 raise ValidationError({
                 raise ValidationError({
-                    'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
+                    'primary_ip6': _("{primary_ip6} is not an IPv6 address.").format(primary_ip6=self.primary_ip6m)
                 })
                 })
             if self.primary_ip6.assigned_object in vc_interfaces:
             if self.primary_ip6.assigned_object in vc_interfaces:
                 pass
                 pass
@@ -893,7 +924,9 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
                 pass
                 pass
             else:
             else:
                 raise ValidationError({
                 raise ValidationError({
-                    'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
+                    'primary_ip6': _(
+                        "The specified IP address ({primary_ip6}) is not assigned to this device."
+                    ).format(primary_ip6=self.primary_ip6)
                 })
                 })
         if self.oob_ip:
         if self.oob_ip:
             if self.oob_ip.assigned_object in vc_interfaces:
             if self.oob_ip.assigned_object in vc_interfaces:
@@ -909,20 +942,25 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
         if hasattr(self, 'device_type') and self.platform:
         if hasattr(self, 'device_type') and self.platform:
             if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
             if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
                 raise ValidationError({
                 raise ValidationError({
-                    'platform': f"The assigned platform is limited to {self.platform.manufacturer} device types, but "
-                                f"this device's type belongs to {self.device_type.manufacturer}."
+                    'platform': _(
+                        "The assigned platform is limited to {platform_manufacturer} device types, but this device's "
+                        "type belongs to {device_type_manufacturer}."
+                    ).format(
+                        platform_manufacturer=self.platform.manufacturer,
+                        device_type_manufacturer=self.device_type.manufacturer
+                    )
                 })
                 })
 
 
         # A Device can only be assigned to a Cluster in the same Site (or no Site)
         # A Device can only be assigned to a Cluster in the same Site (or no Site)
         if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
         if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
             raise ValidationError({
             raise ValidationError({
-                'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
+                'cluster': _("The assigned cluster belongs to a different site ({})").format(self.cluster.site)
             })
             })
 
 
         # Validate virtual chassis assignment
         # Validate virtual chassis assignment
         if self.virtual_chassis and self.vc_position is None:
         if self.virtual_chassis and self.vc_position is None:
             raise ValidationError({
             raise ValidationError({
-                'vc_position': "A device assigned to a virtual chassis must have its position defined."
+                'vc_position': _("A device assigned to a virtual chassis must have its position defined.")
             })
             })
 
 
     def _instantiate_components(self, queryset, bulk_create=True):
     def _instantiate_components(self, queryset, bulk_create=True):
@@ -1107,6 +1145,7 @@ class Module(PrimaryModel, ConfigContextModel):
         related_name='instances'
         related_name='instances'
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=ModuleStatusChoices,
         choices=ModuleStatusChoices,
         default=ModuleStatusChoices.STATUS_ACTIVE
         default=ModuleStatusChoices.STATUS_ACTIVE
@@ -1114,14 +1153,14 @@ class Module(PrimaryModel, ConfigContextModel):
     serial = models.CharField(
     serial = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
-        verbose_name='Serial number'
+        verbose_name=_('serial number')
     )
     )
     asset_tag = models.CharField(
     asset_tag = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         null=True,
         null=True,
         unique=True,
         unique=True,
-        verbose_name='Asset tag',
+        verbose_name=_('asset tag'),
         help_text=_('A unique tag used to identify this device')
         help_text=_('A unique tag used to identify this device')
     )
     )
 
 
@@ -1144,7 +1183,9 @@ class Module(PrimaryModel, ConfigContextModel):
 
 
         if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
         if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
             raise ValidationError(
             raise ValidationError(
-                f"Module must be installed within a module bay belonging to the assigned device ({self.device})."
+                _("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
+                    device=self.device
+                )
             )
             )
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -1242,9 +1283,11 @@ class VirtualChassis(PrimaryModel):
         null=True
         null=True
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=64
         max_length=64
     )
     )
     domain = models.CharField(
     domain = models.CharField(
+        verbose_name=_('domain'),
         max_length=30,
         max_length=30,
         blank=True
         blank=True
     )
     )
@@ -1272,7 +1315,9 @@ class VirtualChassis(PrimaryModel):
         # VirtualChassis.)
         # VirtualChassis.)
         if self.pk and self.master and self.master not in self.members.all():
         if self.pk and self.master and self.master not in self.members.all():
             raise ValidationError({
             raise ValidationError({
-                'master': f"The selected master ({self.master}) is not assigned to this virtual chassis."
+                'master': _("The selected master ({master}) is not assigned to this virtual chassis.").format(
+                    master=self.master
+                )
             })
             })
 
 
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
@@ -1285,10 +1330,10 @@ class VirtualChassis(PrimaryModel):
             lag__device=F('device')
             lag__device=F('device')
         )
         )
         if interfaces:
         if interfaces:
-            raise ProtectedError(
-                f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG",
-                interfaces
-            )
+            raise ProtectedError(_(
+                "Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG "
+                "interfaces."
+            ).format(self=self, interfaces=InterfaceSpeedChoices))
 
 
         return super().delete(*args, **kwargs)
         return super().delete(*args, **kwargs)
 
 
@@ -1302,14 +1347,17 @@ class VirtualDeviceContext(PrimaryModel):
         null=True
         null=True
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=64
         max_length=64
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=VirtualDeviceContextStatusChoices,
         choices=VirtualDeviceContextStatusChoices,
     )
     )
     identifier = models.PositiveSmallIntegerField(
     identifier = models.PositiveSmallIntegerField(
-        help_text='Numeric identifier unique to the parent device',
+        verbose_name=_('identifier'),
+        help_text=_('Numeric identifier unique to the parent device'),
         blank=True,
         blank=True,
         null=True,
         null=True,
     )
     )
@@ -1319,7 +1367,7 @@ class VirtualDeviceContext(PrimaryModel):
         related_name='+',
         related_name='+',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Primary IPv4'
+        verbose_name=_('primary IPv4')
     )
     )
     primary_ip6 = models.OneToOneField(
     primary_ip6 = models.OneToOneField(
         to='ipam.IPAddress',
         to='ipam.IPAddress',
@@ -1327,7 +1375,7 @@ class VirtualDeviceContext(PrimaryModel):
         related_name='+',
         related_name='+',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Primary IPv6'
+        verbose_name=_('primary IPv6')
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
@@ -1337,6 +1385,7 @@ class VirtualDeviceContext(PrimaryModel):
         null=True
         null=True
     )
     )
     comments = models.TextField(
     comments = models.TextField(
+        verbose_name=_('comments'),
         blank=True
         blank=True
     )
     )
 
 
@@ -1382,7 +1431,9 @@ class VirtualDeviceContext(PrimaryModel):
                 continue
                 continue
             if primary_ip.family != family:
             if primary_ip.family != family:
                 raise ValidationError({
                 raise ValidationError({
-                    f'primary_ip{family}': f"{primary_ip} is not an IPv{family} address."
+                    f'primary_ip{family}': _(
+                        "{primary_ip} is not an IPv{family} address."
+                    ).format(family=family, primary_ip=primary_ip)
                 })
                 })
             device_interfaces = self.device.vc_interfaces(if_master=False)
             device_interfaces = self.device.vc_interfaces(if_master=False)
             if primary_ip.assigned_object not in device_interfaces:
             if primary_ip.assigned_object not in device_interfaces:

+ 4 - 1
netbox/dcim/models/mixins.py

@@ -1,17 +1,20 @@
 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_lazy as _
 from dcim.choices import *
 from dcim.choices import *
 from utilities.utils import to_grams
 from utilities.utils import to_grams
 
 
 
 
 class WeightMixin(models.Model):
 class WeightMixin(models.Model):
     weight = models.DecimalField(
     weight = models.DecimalField(
+        verbose_name=_('weight'),
         max_digits=8,
         max_digits=8,
         decimal_places=2,
         decimal_places=2,
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
     weight_unit = models.CharField(
     weight_unit = models.CharField(
+        verbose_name=_('weight unit'),
         max_length=50,
         max_length=50,
         choices=WeightUnitChoices,
         choices=WeightUnitChoices,
         blank=True,
         blank=True,
@@ -40,4 +43,4 @@ class WeightMixin(models.Model):
 
 
         # Validate weight and weight_unit
         # Validate weight and weight_unit
         if self.weight and not self.weight_unit:
         if self.weight and not self.weight_unit:
-            raise ValidationError("Must specify a unit when setting a weight")
+            raise ValidationError(_("Must specify a unit when setting a weight"))

+ 15 - 4
netbox/dcim/models/power.py

@@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 
 from dcim.choices import *
 from dcim.choices import *
 from netbox.config import ConfigItem
 from netbox.config import ConfigItem
@@ -36,6 +36,7 @@ class PowerPanel(PrimaryModel):
         null=True
         null=True
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
 
 
@@ -72,7 +73,8 @@ class PowerPanel(PrimaryModel):
         # Location must belong to assigned Site
         # Location must belong to assigned Site
         if self.location and self.location.site != self.site:
         if self.location and self.location.site != self.site:
             raise ValidationError(
             raise ValidationError(
-                f"Location {self.location} ({self.location.site}) is in a different site than {self.site}"
+                _("Location {location} ({location_site}) is in a different site than {site}").format(
+                    location=self.location, location_site=self.location.site, site=self.site)
             )
             )
 
 
 
 
@@ -92,42 +94,51 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
         null=True
         null=True
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=PowerFeedStatusChoices,
         choices=PowerFeedStatusChoices,
         default=PowerFeedStatusChoices.STATUS_ACTIVE
         default=PowerFeedStatusChoices.STATUS_ACTIVE
     )
     )
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=PowerFeedTypeChoices,
         choices=PowerFeedTypeChoices,
         default=PowerFeedTypeChoices.TYPE_PRIMARY
         default=PowerFeedTypeChoices.TYPE_PRIMARY
     )
     )
     supply = models.CharField(
     supply = models.CharField(
+        verbose_name=_('supply'),
         max_length=50,
         max_length=50,
         choices=PowerFeedSupplyChoices,
         choices=PowerFeedSupplyChoices,
         default=PowerFeedSupplyChoices.SUPPLY_AC
         default=PowerFeedSupplyChoices.SUPPLY_AC
     )
     )
     phase = models.CharField(
     phase = models.CharField(
+        verbose_name=_('phase'),
         max_length=50,
         max_length=50,
         choices=PowerFeedPhaseChoices,
         choices=PowerFeedPhaseChoices,
         default=PowerFeedPhaseChoices.PHASE_SINGLE
         default=PowerFeedPhaseChoices.PHASE_SINGLE
     )
     )
     voltage = models.SmallIntegerField(
     voltage = models.SmallIntegerField(
+        verbose_name=_('voltage'),
         default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'),
         default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'),
         validators=[ExclusionValidator([0])]
         validators=[ExclusionValidator([0])]
     )
     )
     amperage = models.PositiveSmallIntegerField(
     amperage = models.PositiveSmallIntegerField(
+        verbose_name=_('amperage'),
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],
         default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE')
         default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE')
     )
     )
     max_utilization = models.PositiveSmallIntegerField(
     max_utilization = models.PositiveSmallIntegerField(
+        verbose_name=_('max utilization'),
         validators=[MinValueValidator(1), MaxValueValidator(100)],
         validators=[MinValueValidator(1), MaxValueValidator(100)],
         default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
         default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
         help_text=_("Maximum permissible draw (percentage)")
         help_text=_("Maximum permissible draw (percentage)")
     )
     )
     available_power = models.PositiveIntegerField(
     available_power = models.PositiveIntegerField(
+        verbose_name=_('available power'),
         default=0,
         default=0,
         editable=False
         editable=False
     )
     )
@@ -167,14 +178,14 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
 
 
         # Rack must belong to same Site as PowerPanel
         # Rack must belong to same Site as PowerPanel
         if self.rack and self.rack.site != self.power_panel.site:
         if self.rack and self.rack.site != self.power_panel.site:
-            raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format(
+            raise ValidationError(_("Rack {} ({}) and power panel {} ({}) are in different sites").format(
                 self.rack, self.rack.site, self.power_panel, self.power_panel.site
                 self.rack, self.rack.site, self.power_panel, self.power_panel.site
             ))
             ))
 
 
         # AC voltage cannot be negative
         # AC voltage cannot be negative
         if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
         if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
             raise ValidationError({
             raise ValidationError({
-                "voltage": "Voltage cannot be negative for AC supply"
+                "voltage": _("Voltage cannot be negative for AC supply")
             })
             })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):

+ 28 - 18
netbox/dcim/models/racks.py

@@ -9,7 +9,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.db.models import Count
 from django.db.models import Count
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
@@ -39,6 +39,7 @@ class RackRole(OrganizationalModel):
     Racks can be organized by functional role, similar to Devices.
     Racks can be organized by functional role, similar to Devices.
     """
     """
     color = ColorField(
     color = ColorField(
+        verbose_name=_('color'),
         default=ColorChoices.COLOR_GREY
         default=ColorChoices.COLOR_GREY
     )
     )
 
 
@@ -52,6 +53,7 @@ class Rack(PrimaryModel, WeightMixin):
     Each Rack is assigned to a Site and (optionally) a Location.
     Each Rack is assigned to a Site and (optionally) a Location.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     _name = NaturalOrderingField(
     _name = NaturalOrderingField(
@@ -63,7 +65,7 @@ class Rack(PrimaryModel, WeightMixin):
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Facility ID',
+        verbose_name=_('facility ID'),
         help_text=_("Locally-assigned identifier")
         help_text=_("Locally-assigned identifier")
     )
     )
     site = models.ForeignKey(
     site = models.ForeignKey(
@@ -86,6 +88,7 @@ class Rack(PrimaryModel, WeightMixin):
         null=True
         null=True
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=RackStatusChoices,
         choices=RackStatusChoices,
         default=RackStatusChoices.STATUS_ACTIVE
         default=RackStatusChoices.STATUS_ACTIVE
@@ -101,60 +104,64 @@ class Rack(PrimaryModel, WeightMixin):
     serial = models.CharField(
     serial = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
-        verbose_name='Serial number'
+        verbose_name=_('serial number')
     )
     )
     asset_tag = models.CharField(
     asset_tag = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         null=True,
         null=True,
         unique=True,
         unique=True,
-        verbose_name='Asset tag',
+        verbose_name=_('asset tag'),
         help_text=_('A unique tag used to identify this rack')
         help_text=_('A unique tag used to identify this rack')
     )
     )
     type = models.CharField(
     type = models.CharField(
         choices=RackTypeChoices,
         choices=RackTypeChoices,
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
-        verbose_name='Type'
+        verbose_name=_('type')
     )
     )
     width = models.PositiveSmallIntegerField(
     width = models.PositiveSmallIntegerField(
         choices=RackWidthChoices,
         choices=RackWidthChoices,
         default=RackWidthChoices.WIDTH_19IN,
         default=RackWidthChoices.WIDTH_19IN,
-        verbose_name='Width',
+        verbose_name=_('width'),
         help_text=_('Rail-to-rail width')
         help_text=_('Rail-to-rail width')
     )
     )
     u_height = models.PositiveSmallIntegerField(
     u_height = models.PositiveSmallIntegerField(
         default=RACK_U_HEIGHT_DEFAULT,
         default=RACK_U_HEIGHT_DEFAULT,
-        verbose_name='Height (U)',
+        verbose_name=_('height (U)'),
         validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
         validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
         help_text=_('Height in rack units')
         help_text=_('Height in rack units')
     )
     )
     starting_unit = models.PositiveSmallIntegerField(
     starting_unit = models.PositiveSmallIntegerField(
         default=RACK_STARTING_UNIT_DEFAULT,
         default=RACK_STARTING_UNIT_DEFAULT,
-        verbose_name='Starting unit',
+        verbose_name=_('starting unit'),
         help_text=_('Starting unit for rack')
         help_text=_('Starting unit for rack')
     )
     )
     desc_units = models.BooleanField(
     desc_units = models.BooleanField(
         default=False,
         default=False,
-        verbose_name='Descending units',
+        verbose_name=_('descending units'),
         help_text=_('Units are numbered top-to-bottom')
         help_text=_('Units are numbered top-to-bottom')
     )
     )
     outer_width = models.PositiveSmallIntegerField(
     outer_width = models.PositiveSmallIntegerField(
+        verbose_name=_('outer width'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         help_text=_('Outer dimension of rack (width)')
         help_text=_('Outer dimension of rack (width)')
     )
     )
     outer_depth = models.PositiveSmallIntegerField(
     outer_depth = models.PositiveSmallIntegerField(
+        verbose_name=_('outer depth'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         help_text=_('Outer dimension of rack (depth)')
         help_text=_('Outer dimension of rack (depth)')
     )
     )
     outer_unit = models.CharField(
     outer_unit = models.CharField(
+        verbose_name=_('outer unit'),
         max_length=50,
         max_length=50,
         choices=RackDimensionUnitChoices,
         choices=RackDimensionUnitChoices,
         blank=True,
         blank=True,
     )
     )
     max_weight = models.PositiveIntegerField(
     max_weight = models.PositiveIntegerField(
+        verbose_name=_('max weight'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         help_text=_('Maximum load capacity for the rack')
         help_text=_('Maximum load capacity for the rack')
@@ -165,6 +172,7 @@ class Rack(PrimaryModel, WeightMixin):
         null=True
         null=True
     )
     )
     mounting_depth = models.PositiveSmallIntegerField(
     mounting_depth = models.PositiveSmallIntegerField(
+        verbose_name=_('mounting depth'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         help_text=(
         help_text=(
@@ -222,15 +230,15 @@ class Rack(PrimaryModel, WeightMixin):
 
 
         # Validate location/site assignment
         # Validate location/site assignment
         if self.site and self.location and self.location.site != self.site:
         if self.site and self.location and self.location.site != self.site:
-            raise ValidationError(f"Assigned location must belong to parent site ({self.site}).")
+            raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site))
 
 
         # Validate outer dimensions and unit
         # Validate outer dimensions and unit
         if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
         if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
-            raise ValidationError("Must specify a unit when setting an outer width/depth")
+            raise ValidationError(_("Must specify a unit when setting an outer width/depth"))
 
 
         # Validate max_weight and weight_unit
         # Validate max_weight and weight_unit
         if self.max_weight and not self.weight_unit:
         if self.max_weight and not self.weight_unit:
-            raise ValidationError("Must specify a unit when setting a maximum weight")
+            raise ValidationError(_("Must specify a unit when setting a maximum weight"))
 
 
         if self.pk:
         if self.pk:
             mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
             mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
@@ -240,22 +248,22 @@ class Rack(PrimaryModel, WeightMixin):
                 min_height = top_device.position + top_device.device_type.u_height - self.starting_unit
                 min_height = top_device.position + top_device.device_type.u_height - self.starting_unit
                 if self.u_height < min_height:
                 if self.u_height < min_height:
                     raise ValidationError({
                     raise ValidationError({
-                        'u_height': f"Rack must be at least {min_height}U tall to house currently installed devices."
+                        'u_height': _("Rack must be at least {min_height}U tall to house currently installed devices.").format(min_height=min_height)
                     })
                     })
 
 
             # Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device
             # Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device
             if last_device := mounted_devices.first():
             if last_device := mounted_devices.first():
                 if self.starting_unit > last_device.position:
                 if self.starting_unit > last_device.position:
                     raise ValidationError({
                     raise ValidationError({
-                        'starting_unit': f"Rack unit numbering must begin at {last_device.position} or less to house "
-                                         f"currently installed devices."
+                        'starting_unit': _("Rack unit numbering must begin at {position} or less to house "
+                                           "currently installed devices.").format(position=last_device.position)
                     })
                     })
 
 
             # Validate that Rack was assigned a Location of its same site, if applicable
             # Validate that Rack was assigned a Location of its same site, if applicable
             if self.location:
             if self.location:
                 if self.location.site != self.site:
                 if self.location.site != self.site:
                     raise ValidationError({
                     raise ValidationError({
-                        'location': f"Location must be from the same site, {self.site}."
+                        'location': _("Location must be from the same site, {site}.").format(site=self.site)
                     })
                     })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -504,6 +512,7 @@ class RackReservation(PrimaryModel):
         related_name='reservations'
         related_name='reservations'
     )
     )
     units = ArrayField(
     units = ArrayField(
+        verbose_name=_('units'),
         base_field=models.PositiveSmallIntegerField()
         base_field=models.PositiveSmallIntegerField()
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
@@ -518,6 +527,7 @@ class RackReservation(PrimaryModel):
         on_delete=models.PROTECT
         on_delete=models.PROTECT
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200
         max_length=200
     )
     )
 
 
@@ -544,7 +554,7 @@ class RackReservation(PrimaryModel):
             invalid_units = [u for u in self.units if u not in self.rack.units]
             invalid_units = [u for u in self.units if u not in self.rack.units]
             if invalid_units:
             if invalid_units:
                 raise ValidationError({
                 raise ValidationError({
-                    'units': "Invalid unit(s) for {}U rack: {}".format(
+                    'units': _("Invalid unit(s) for {}U rack: {}").format(
                         self.rack.u_height,
                         self.rack.u_height,
                         ', '.join([str(u) for u in invalid_units]),
                         ', '.join([str(u) for u in invalid_units]),
                     ),
                     ),
@@ -557,7 +567,7 @@ class RackReservation(PrimaryModel):
             conflicting_units = [u for u in self.units if u in reserved_units]
             conflicting_units = [u for u in self.units if u in reserved_units]
             if conflicting_units:
             if conflicting_units:
                 raise ValidationError({
                 raise ValidationError({
-                    'units': 'The following units have already been reserved: {}'.format(
+                    'units': _('The following units have already been reserved: {}').format(
                         ', '.join([str(u) for u in conflicting_units]),
                         ', '.join([str(u) for u in conflicting_units]),
                     )
                     )
                 })
                 })

+ 24 - 13
netbox/dcim/models/sites.py

@@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
 
 
 from dcim.choices import *
 from dcim.choices import *
@@ -49,7 +49,7 @@ class Region(NestedGroupModel):
                 fields=('name',),
                 fields=('name',),
                 name='%(app_label)s_%(class)s_name',
                 name='%(app_label)s_%(class)s_name',
                 condition=Q(parent__isnull=True),
                 condition=Q(parent__isnull=True),
-                violation_error_message="A top-level region with this name already exists."
+                violation_error_message=_("A top-level region with this name already exists.")
             ),
             ),
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('parent', 'slug'),
                 fields=('parent', 'slug'),
@@ -59,7 +59,7 @@ class Region(NestedGroupModel):
                 fields=('slug',),
                 fields=('slug',),
                 name='%(app_label)s_%(class)s_slug',
                 name='%(app_label)s_%(class)s_slug',
                 condition=Q(parent__isnull=True),
                 condition=Q(parent__isnull=True),
-                violation_error_message="A top-level region with this slug already exists."
+                violation_error_message=_("A top-level region with this slug already exists.")
             ),
             ),
         )
         )
 
 
@@ -104,7 +104,7 @@ class SiteGroup(NestedGroupModel):
                 fields=('name',),
                 fields=('name',),
                 name='%(app_label)s_%(class)s_name',
                 name='%(app_label)s_%(class)s_name',
                 condition=Q(parent__isnull=True),
                 condition=Q(parent__isnull=True),
-                violation_error_message="A top-level site group with this name already exists."
+                violation_error_message=_("A top-level site group with this name already exists.")
             ),
             ),
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('parent', 'slug'),
                 fields=('parent', 'slug'),
@@ -114,7 +114,7 @@ class SiteGroup(NestedGroupModel):
                 fields=('slug',),
                 fields=('slug',),
                 name='%(app_label)s_%(class)s_slug',
                 name='%(app_label)s_%(class)s_slug',
                 condition=Q(parent__isnull=True),
                 condition=Q(parent__isnull=True),
-                violation_error_message="A top-level site group with this slug already exists."
+                violation_error_message=_("A top-level site group with this slug already exists.")
             ),
             ),
         )
         )
 
 
@@ -138,6 +138,7 @@ class Site(PrimaryModel):
     field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
     field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True,
         unique=True,
         help_text=_("Full name of the site")
         help_text=_("Full name of the site")
@@ -148,10 +149,12 @@ class Site(PrimaryModel):
         blank=True
         blank=True
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=SiteStatusChoices,
         choices=SiteStatusChoices,
         default=SiteStatusChoices.STATUS_ACTIVE
         default=SiteStatusChoices.STATUS_ACTIVE
@@ -178,9 +181,10 @@ class Site(PrimaryModel):
         null=True
         null=True
     )
     )
     facility = models.CharField(
     facility = models.CharField(
+        verbose_name=_('facility'),
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
-        help_text=_("Local facility ID or description")
+        help_text=_('Local facility ID or description')
     )
     )
     asns = models.ManyToManyField(
     asns = models.ManyToManyField(
         to='ipam.ASN',
         to='ipam.ASN',
@@ -191,28 +195,32 @@ class Site(PrimaryModel):
         blank=True
         blank=True
     )
     )
     physical_address = models.CharField(
     physical_address = models.CharField(
+        verbose_name=_('physical address'),
         max_length=200,
         max_length=200,
         blank=True,
         blank=True,
-        help_text=_("Physical location of the building")
+        help_text=_('Physical location of the building')
     )
     )
     shipping_address = models.CharField(
     shipping_address = models.CharField(
+        verbose_name=_('shipping address'),
         max_length=200,
         max_length=200,
         blank=True,
         blank=True,
-        help_text=_("If different from the physical address")
+        help_text=_('If different from the physical address')
     )
     )
     latitude = models.DecimalField(
     latitude = models.DecimalField(
+        verbose_name=_('latitude'),
         max_digits=8,
         max_digits=8,
         decimal_places=6,
         decimal_places=6,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
+        help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
     )
     )
     longitude = models.DecimalField(
     longitude = models.DecimalField(
+        verbose_name=_('longitude'),
         max_digits=9,
         max_digits=9,
         decimal_places=6,
         decimal_places=6,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
+        help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
     )
     )
 
 
     # Generic relations
     # Generic relations
@@ -262,6 +270,7 @@ class Location(NestedGroupModel):
         related_name='locations'
         related_name='locations'
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=LocationStatusChoices,
         choices=LocationStatusChoices,
         default=LocationStatusChoices.STATUS_ACTIVE
         default=LocationStatusChoices.STATUS_ACTIVE
@@ -304,7 +313,7 @@ class Location(NestedGroupModel):
                 fields=('site', 'name'),
                 fields=('site', 'name'),
                 name='%(app_label)s_%(class)s_name',
                 name='%(app_label)s_%(class)s_name',
                 condition=Q(parent__isnull=True),
                 condition=Q(parent__isnull=True),
-                violation_error_message="A location with this name already exists within the specified site."
+                violation_error_message=_("A location with this name already exists within the specified site.")
             ),
             ),
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('site', 'parent', 'slug'),
                 fields=('site', 'parent', 'slug'),
@@ -314,7 +323,7 @@ class Location(NestedGroupModel):
                 fields=('site', 'slug'),
                 fields=('site', 'slug'),
                 name='%(app_label)s_%(class)s_slug',
                 name='%(app_label)s_%(class)s_slug',
                 condition=Q(parent__isnull=True),
                 condition=Q(parent__isnull=True),
-                violation_error_message="A location with this slug already exists within the specified site."
+                violation_error_message=_("A location with this slug already exists within the specified site.")
             ),
             ),
         )
         )
 
 
@@ -329,4 +338,6 @@ class Location(NestedGroupModel):
 
 
         # Parent Location (if any) must belong to the same Site
         # Parent Location (if any) must belong to the same Site
         if self.parent and self.parent.site != self.site:
         if self.parent and self.parent.site != self.site:
-            raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})")
+            raise ValidationError(_(
+                "Parent location ({parent}) must belong to the same site ({site})."
+            ).format(parent=self.parent, site=self.site))

+ 7 - 0
netbox/extras/models/change_logging.py

@@ -3,6 +3,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
 
 
 from extras.choices import *
 from extras.choices import *
 from ..querysets import ObjectChangeQuerySet
 from ..querysets import ObjectChangeQuerySet
@@ -19,6 +20,7 @@ class ObjectChange(models.Model):
     parent device. This will ensure changes made to component models appear in the parent model's changelog.
     parent device. This will ensure changes made to component models appear in the parent model's changelog.
     """
     """
     time = models.DateTimeField(
     time = models.DateTimeField(
+        verbose_name=_('time'),
         auto_now_add=True,
         auto_now_add=True,
         editable=False,
         editable=False,
         db_index=True
         db_index=True
@@ -31,14 +33,17 @@ class ObjectChange(models.Model):
         null=True
         null=True
     )
     )
     user_name = models.CharField(
     user_name = models.CharField(
+        verbose_name=_('user name'),
         max_length=150,
         max_length=150,
         editable=False
         editable=False
     )
     )
     request_id = models.UUIDField(
     request_id = models.UUIDField(
+        verbose_name=_('request ID'),
         editable=False,
         editable=False,
         db_index=True
         db_index=True
     )
     )
     action = models.CharField(
     action = models.CharField(
+        verbose_name=_('action'),
         max_length=50,
         max_length=50,
         choices=ObjectChangeActionChoices
         choices=ObjectChangeActionChoices
     )
     )
@@ -72,11 +77,13 @@ class ObjectChange(models.Model):
         editable=False
         editable=False
     )
     )
     prechange_data = models.JSONField(
     prechange_data = models.JSONField(
+        verbose_name=_('pre-change data'),
         editable=False,
         editable=False,
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
     postchange_data = models.JSONField(
     postchange_data = models.JSONField(
+        verbose_name=_('post-change data'),
         editable=False,
         editable=False,
         blank=True,
         blank=True,
         null=True
         null=True

+ 11 - 3
netbox/extras/models/configs.py

@@ -2,7 +2,7 @@ from django.conf import settings
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 from jinja2.loaders import BaseLoader
 from jinja2.loaders import BaseLoader
 from jinja2.sandbox import SandboxedEnvironment
 from jinja2.sandbox import SandboxedEnvironment
 
 
@@ -31,17 +31,21 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
     will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
     will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     weight = models.PositiveSmallIntegerField(
     weight = models.PositiveSmallIntegerField(
+        verbose_name=_('weight'),
         default=1000
         default=1000
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
     is_active = models.BooleanField(
     is_active = models.BooleanField(
+        verbose_name=_('is active'),
         default=True,
         default=True,
     )
     )
     regions = models.ManyToManyField(
     regions = models.ManyToManyField(
@@ -138,7 +142,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
         # Verify that JSON data is provided as an object
         # Verify that JSON data is provided as an object
         if type(self.data) is not dict:
         if type(self.data) is not dict:
             raise ValidationError(
             raise ValidationError(
-                {'data': 'JSON data must be in object form. Example: {"foo": 123}'}
+                {'data': _('JSON data must be in object form. Example: {"foo": 123}')}
             )
             )
 
 
     def sync_data(self):
     def sync_data(self):
@@ -194,7 +198,7 @@ class ConfigContextModel(models.Model):
         # Verify that JSON data is provided as an object
         # Verify that JSON data is provided as an object
         if self.local_context_data and type(self.local_context_data) is not dict:
         if self.local_context_data and type(self.local_context_data) is not dict:
             raise ValidationError(
             raise ValidationError(
-                {'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'}
+                {'local_context_data': _('JSON data must be in object form. Example: {"foo": 123}')}
             )
             )
 
 
 
 
@@ -204,16 +208,20 @@ class ConfigContextModel(models.Model):
 
 
 class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
 class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
     template_code = models.TextField(
     template_code = models.TextField(
+        verbose_name=_('template code'),
         help_text=_('Jinja2 template code.')
         help_text=_('Jinja2 template code.')
     )
     )
     environment_params = models.JSONField(
     environment_params = models.JSONField(
+        verbose_name=_('environment parameters'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         default=dict,
         default=dict,

+ 84 - 49
netbox/extras/models/customfields.py

@@ -12,7 +12,7 @@ from django.db import models
 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 django.utils.translation import gettext_lazy as _
 
 
 from extras.choices import *
 from extras.choices import *
 from extras.data import CHOICE_SETS
 from extras.data import CHOICE_SETS
@@ -65,6 +65,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         help_text=_('The object(s) to which this field applies.')
         help_text=_('The object(s) to which this field applies.')
     )
     )
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=CustomFieldTypeChoices,
         choices=CustomFieldTypeChoices,
         default=CustomFieldTypeChoices.TYPE_TEXT,
         default=CustomFieldTypeChoices.TYPE_TEXT,
@@ -78,83 +79,93 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         help_text=_('The type of NetBox object this field maps to (for object fields)')
         help_text=_('The type of NetBox object this field maps to (for object fields)')
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=50,
         max_length=50,
         unique=True,
         unique=True,
         help_text=_('Internal field name'),
         help_text=_('Internal field name'),
         validators=(
         validators=(
             RegexValidator(
             RegexValidator(
                 regex=r'^[a-z0-9_]+$',
                 regex=r'^[a-z0-9_]+$',
-                message="Only alphanumeric characters and underscores are allowed.",
+                message=_("Only alphanumeric characters and underscores are allowed."),
                 flags=re.IGNORECASE
                 flags=re.IGNORECASE
             ),
             ),
             RegexValidator(
             RegexValidator(
                 regex=r'__',
                 regex=r'__',
-                message="Double underscores are not permitted in custom field names.",
+                message=_("Double underscores are not permitted in custom field names."),
                 flags=re.IGNORECASE,
                 flags=re.IGNORECASE,
                 inverse_match=True
                 inverse_match=True
             ),
             ),
         )
         )
     )
     )
     label = models.CharField(
     label = models.CharField(
+        verbose_name=_('label'),
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
-        help_text=_('Name of the field as displayed to users (if not provided, '
-                    'the field\'s name will be used)')
+        help_text=_(
+            "Name of the field as displayed to users (if not provided, 'the field's name will be used)"
+        )
     )
     )
     group_name = models.CharField(
     group_name = models.CharField(
+        verbose_name=_('group name'),
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         help_text=_("Custom fields within the same group will be displayed together")
         help_text=_("Custom fields within the same group will be displayed together")
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
     required = models.BooleanField(
     required = models.BooleanField(
+        verbose_name=_('required'),
         default=False,
         default=False,
-        help_text=_('If true, this field is required when creating new objects '
-                    'or editing an existing object.')
+        help_text=_("If true, this field is required when creating new objects or editing an existing object.")
     )
     )
     search_weight = models.PositiveSmallIntegerField(
     search_weight = models.PositiveSmallIntegerField(
+        verbose_name=_('search weight'),
         default=1000,
         default=1000,
-        help_text=_('Weighting for search. Lower values are considered more important. '
-                    'Fields with a search weight of zero will be ignored.')
+        help_text=_(
+            "Weighting for search. Lower values are considered more important. Fields with a search weight of zero "
+            "will be ignored."
+        )
     )
     )
     filter_logic = models.CharField(
     filter_logic = models.CharField(
+        verbose_name=_('filter logic'),
         max_length=50,
         max_length=50,
         choices=CustomFieldFilterLogicChoices,
         choices=CustomFieldFilterLogicChoices,
         default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
         default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
-        help_text=_('Loose matches any instance of a given string; exact '
-                    'matches the entire field.')
+        help_text=_("Loose matches any instance of a given string; exact matches the entire field.")
     )
     )
     default = models.JSONField(
     default = models.JSONField(
+        verbose_name=_('default'),
         blank=True,
         blank=True,
         null=True,
         null=True,
-        help_text=_('Default value for the field (must be a JSON value). Encapsulate '
-                    'strings with double quotes (e.g. "Foo").')
+        help_text=_(
+            'Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. "Foo").'
+        )
     )
     )
     weight = models.PositiveSmallIntegerField(
     weight = models.PositiveSmallIntegerField(
         default=100,
         default=100,
-        verbose_name='Display weight',
+        verbose_name=_('display weight'),
         help_text=_('Fields with higher weights appear lower in a form.')
         help_text=_('Fields with higher weights appear lower in a form.')
     )
     )
     validation_minimum = models.IntegerField(
     validation_minimum = models.IntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Minimum value',
+        verbose_name=_('minimum value'),
         help_text=_('Minimum allowed value (for numeric fields)')
         help_text=_('Minimum allowed value (for numeric fields)')
     )
     )
     validation_maximum = models.IntegerField(
     validation_maximum = models.IntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Maximum value',
+        verbose_name=_('maximum value'),
         help_text=_('Maximum allowed value (for numeric fields)')
         help_text=_('Maximum allowed value (for numeric fields)')
     )
     )
     validation_regex = models.CharField(
     validation_regex = models.CharField(
         blank=True,
         blank=True,
         validators=[validate_regex],
         validators=[validate_regex],
         max_length=500,
         max_length=500,
-        verbose_name='Validation regex',
+        verbose_name=_('validation regex'),
         help_text=_(
         help_text=_(
             'Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. For '
             'Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. For '
             'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
             'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
@@ -164,6 +175,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         to='CustomFieldChoiceSet',
         to='CustomFieldChoiceSet',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='choices_for',
         related_name='choices_for',
+        verbose_name=_('choice set'),
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
@@ -171,12 +183,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         max_length=50,
         max_length=50,
         choices=CustomFieldVisibilityChoices,
         choices=CustomFieldVisibilityChoices,
         default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
         default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
-        verbose_name='UI visibility',
+        verbose_name=_('UI visibility'),
         help_text=_('Specifies the visibility of custom field in the UI')
         help_text=_('Specifies the visibility of custom field in the UI')
     )
     )
     is_cloneable = models.BooleanField(
     is_cloneable = models.BooleanField(
         default=False,
         default=False,
-        verbose_name='Cloneable',
+        verbose_name=_('is cloneable'),
         help_text=_('Replicate this value when cloning objects')
         help_text=_('Replicate this value when cloning objects')
     )
     )
 
 
@@ -266,15 +278,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
                 self.validate(default_value)
                 self.validate(default_value)
             except ValidationError as err:
             except ValidationError as err:
                 raise ValidationError({
                 raise ValidationError({
-                    'default': f'Invalid default value "{self.default}": {err.message}'
+                    'default': _(
+                        'Invalid default value "{default}": {message}'
+                    ).format(default=self.default, message=self.message)
                 })
                 })
 
 
         # Minimum/maximum values can be set only for numeric fields
         # Minimum/maximum values can be set only for numeric fields
         if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
         if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
             if self.validation_minimum:
             if self.validation_minimum:
-                raise ValidationError({'validation_minimum': "A minimum value may be set only for numeric fields"})
+                raise ValidationError({'validation_minimum': _("A minimum value may be set only for numeric fields")})
             if self.validation_maximum:
             if self.validation_maximum:
-                raise ValidationError({'validation_maximum': "A maximum value may be set only for numeric fields"})
+                raise ValidationError({'validation_maximum': _("A maximum value may be set only for numeric fields")})
 
 
         # Regex validation can be set only for text fields
         # Regex validation can be set only for text fields
         regex_types = (
         regex_types = (
@@ -284,7 +298,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         )
         )
         if self.validation_regex and self.type not in regex_types:
         if self.validation_regex and self.type not in regex_types:
             raise ValidationError({
             raise ValidationError({
-                'validation_regex': "Regular expression validation is supported only for text and URL fields"
+                'validation_regex': _("Regular expression validation is supported only for text and URL fields")
             })
             })
 
 
         # Choice set must be set on selection fields, and *only* on selection fields
         # Choice set must be set on selection fields, and *only* on selection fields
@@ -294,28 +308,32 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         ):
         ):
             if not self.choice_set:
             if not self.choice_set:
                 raise ValidationError({
                 raise ValidationError({
-                    'choice_set': "Selection fields must specify a set of choices."
+                    'choice_set': _("Selection fields must specify a set of choices.")
                 })
                 })
         elif self.choice_set:
         elif self.choice_set:
             raise ValidationError({
             raise ValidationError({
-                'choice_set': "Choices may be set only on selection fields."
+                'choice_set': _("Choices may be set only on selection fields.")
             })
             })
 
 
         # A selection field's default (if any) must be present in its available choices
         # A selection field's default (if any) must be present in its available choices
         if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
         if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
             raise ValidationError({
             raise ValidationError({
-                'default': f"The specified default value ({self.default}) is not listed as an available choice."
+                'default': _(
+                    "The specified default value ({default}) is not listed as an available choice."
+                ).format(default=self.default)
             })
             })
 
 
         # Object fields must define an object_type; other fields must not
         # Object fields must define an object_type; other fields must not
         if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
         if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
             if not self.object_type:
             if not self.object_type:
                 raise ValidationError({
                 raise ValidationError({
-                    'object_type': "Object fields must define an object type."
+                    'object_type': _("Object fields must define an object type.")
                 })
                 })
         elif self.object_type:
         elif self.object_type:
             raise ValidationError({
             raise ValidationError({
-                'object_type': f"{self.get_type_display()} fields may not define an object type."
+                'object_type': _(
+                    "{type_display} fields may not define an object type.")
+                .format(type_display=self.get_type_display())
             })
             })
 
 
     def serialize(self, value):
     def serialize(self, value):
@@ -394,8 +412,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
         elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
             choices = (
             choices = (
                 (None, '---------'),
                 (None, '---------'),
-                (True, 'True'),
-                (False, 'False'),
+                (True, _('True')),
+                (False, _('False')),
             )
             )
             field = forms.NullBooleanField(
             field = forms.NullBooleanField(
                 required=required, initial=initial, widget=forms.Select(choices=choices)
                 required=required, initial=initial, widget=forms.Select(choices=choices)
@@ -470,7 +488,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
                 field.validators = [
                 field.validators = [
                     RegexValidator(
                     RegexValidator(
                         regex=self.validation_regex,
                         regex=self.validation_regex,
-                        message=mark_safe(f"Values must match this regex: <code>{self.validation_regex}</code>")
+                        message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
+                            regex=self.validation_regex
+                        ))
                     )
                     )
                 ]
                 ]
 
 
@@ -483,7 +503,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
         if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
             field.disabled = True
             field.disabled = True
             prepend = '<br />' if field.help_text else ''
             prepend = '<br />' if field.help_text else ''
-            field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> Field is set to read-only.'
+            field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> ' + _('Field is set to read-only.')
 
 
         return field
         return field
 
 
@@ -565,33 +585,41 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
             # Validate text field
             # Validate text field
             if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
             if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
                 if type(value) is not str:
                 if type(value) is not str:
-                    raise ValidationError(f"Value must be a string.")
+                    raise ValidationError(_("Value must be a string."))
                 if self.validation_regex and not re.match(self.validation_regex, value):
                 if self.validation_regex and not re.match(self.validation_regex, value):
-                    raise ValidationError(f"Value must match regex '{self.validation_regex}'")
+                    raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
 
 
             # Validate integer
             # Validate integer
             elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
             elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
                 if type(value) is not int:
                 if type(value) is not int:
-                    raise ValidationError("Value must be an integer.")
+                    raise ValidationError(_("Value must be an integer."))
                 if self.validation_minimum is not None and value < self.validation_minimum:
                 if self.validation_minimum is not None and value < self.validation_minimum:
-                    raise ValidationError(f"Value must be at least {self.validation_minimum}")
+                    raise ValidationError(
+                        _("Value must be at least {minimum}").format(minimum=self.validation_maximum)
+                    )
                 if self.validation_maximum is not None and value > self.validation_maximum:
                 if self.validation_maximum is not None and value > self.validation_maximum:
-                    raise ValidationError(f"Value must not exceed {self.validation_maximum}")
+                    raise ValidationError(
+                        _("Value must not exceed {maximum}").format(maximum=self.validation_maximum)
+                    )
 
 
             # Validate decimal
             # Validate decimal
             elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
             elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
                 try:
                 try:
                     decimal.Decimal(value)
                     decimal.Decimal(value)
                 except decimal.InvalidOperation:
                 except decimal.InvalidOperation:
-                    raise ValidationError("Value must be a decimal.")
+                    raise ValidationError(_("Value must be a decimal."))
                 if self.validation_minimum is not None and value < self.validation_minimum:
                 if self.validation_minimum is not None and value < self.validation_minimum:
-                    raise ValidationError(f"Value must be at least {self.validation_minimum}")
+                    raise ValidationError(
+                        _("Value must be at least {minimum}").format(minimum=self.validation_minimum)
+                    )
                 if self.validation_maximum is not None and value > self.validation_maximum:
                 if self.validation_maximum is not None and value > self.validation_maximum:
-                    raise ValidationError(f"Value must not exceed {self.validation_maximum}")
+                    raise ValidationError(
+                        _("Value must not exceed {maximum}").format(maximum=self.validation_maximum)
+                    )
 
 
             # Validate boolean
             # Validate boolean
             elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
             elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
-                raise ValidationError("Value must be true or false.")
+                raise ValidationError(_("Value must be true or false."))
 
 
             # Validate date
             # Validate date
             elif self.type == CustomFieldTypeChoices.TYPE_DATE:
             elif self.type == CustomFieldTypeChoices.TYPE_DATE:
@@ -599,7 +627,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
                     try:
                     try:
                         date.fromisoformat(value)
                         date.fromisoformat(value)
                     except ValueError:
                     except ValueError:
-                        raise ValidationError("Date values must be in ISO 8601 format (YYYY-MM-DD).")
+                        raise ValidationError(_("Date values must be in ISO 8601 format (YYYY-MM-DD)."))
 
 
             # Validate date & time
             # Validate date & time
             elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
             elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
@@ -607,37 +635,44 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
                     try:
                     try:
                         datetime.fromisoformat(value)
                         datetime.fromisoformat(value)
                     except ValueError:
                     except ValueError:
-                        raise ValidationError("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).")
+                        raise ValidationError(
+                            _("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).")
+                        )
 
 
             # Validate selected choice
             # Validate selected choice
             elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
             elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
                 if value not in [c[0] for c in self.choices]:
                 if value not in [c[0] for c in self.choices]:
                     raise ValidationError(
                     raise ValidationError(
-                        f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
+                        _("Invalid choice ({value}). Available choices are: {choices}").format(
+                            value=value, choices=', '.join(self.choices)
+                        )
                     )
                     )
 
 
             # Validate all selected choices
             # Validate all selected choices
             elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
             elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
                 if not set(value).issubset([c[0] for c in self.choices]):
                 if not set(value).issubset([c[0] for c in self.choices]):
                     raise ValidationError(
                     raise ValidationError(
-                        f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
+                        _("Invalid choice(s) ({invalid_choices}). Available choices are: {available_choices}").format(
+                            invalid_choices=', '.join(value), available_choices=', '.join(self.choices))
                     )
                     )
 
 
             # Validate selected object
             # Validate selected object
             elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
             elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
                 if type(value) is not int:
                 if type(value) is not int:
-                    raise ValidationError(f"Value must be an object ID, not {type(value).__name__}")
+                    raise ValidationError(_("Value must be an object ID, not {type}").format(type=type(value).__name__))
 
 
             # Validate selected objects
             # Validate selected objects
             elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
             elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
                 if type(value) is not list:
                 if type(value) is not list:
-                    raise ValidationError(f"Value must be a list of object IDs, not {type(value).__name__}")
+                    raise ValidationError(
+                        _("Value must be a list of object IDs, not {type}").format(type=type(value).__name__)
+                    )
                 for id in value:
                 for id in value:
                     if type(id) is not int:
                     if type(id) is not int:
-                        raise ValidationError(f"Found invalid object ID: {id}")
+                        raise ValidationError(_("Found invalid object ID: {id}").format(id=id))
 
 
         elif self.required:
         elif self.required:
-            raise ValidationError("Required field cannot be empty.")
+            raise ValidationError(_("Required field cannot be empty."))
 
 
 
 
 class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):

+ 3 - 0
netbox/extras/models/dashboard.py

@@ -1,5 +1,6 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.db import models
 from django.db import models
+from django.utils.translation import gettext_lazy as _
 
 
 from extras.dashboard.utils import get_widget_class
 from extras.dashboard.utils import get_widget_class
 
 
@@ -15,9 +16,11 @@ class Dashboard(models.Model):
         related_name='dashboard'
         related_name='dashboard'
     )
     )
     layout = models.JSONField(
     layout = models.JSONField(
+        verbose_name=_('layout'),
         default=list
         default=list
     )
     )
     config = models.JSONField(
     config = models.JSONField(
+        verbose_name=_('config'),
         default=dict
         default=dict
     )
     )
 
 

+ 91 - 37
netbox/extras/models/models.py

@@ -12,7 +12,7 @@ from django.http import HttpResponse
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.formats import date_format
 from django.utils.formats import date_format
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 from rest_framework.utils.encoders import JSONEncoder
 from rest_framework.utils.encoders import JSONEncoder
 
 
 from extras.choices import *
 from extras.choices import *
@@ -48,93 +48,113 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
     content_types = models.ManyToManyField(
     content_types = models.ManyToManyField(
         to=ContentType,
         to=ContentType,
         related_name='webhooks',
         related_name='webhooks',
-        verbose_name='Object types',
+        verbose_name=_('object types'),
         limit_choices_to=FeatureQuery('webhooks'),
         limit_choices_to=FeatureQuery('webhooks'),
         help_text=_("The object(s) to which this Webhook applies.")
         help_text=_("The object(s) to which this Webhook applies.")
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=150,
         max_length=150,
         unique=True
         unique=True
     )
     )
     type_create = models.BooleanField(
     type_create = models.BooleanField(
+        verbose_name=_('on create'),
         default=False,
         default=False,
         help_text=_("Triggers when a matching object is created.")
         help_text=_("Triggers when a matching object is created.")
     )
     )
     type_update = models.BooleanField(
     type_update = models.BooleanField(
+        verbose_name=_('on update'),
         default=False,
         default=False,
         help_text=_("Triggers when a matching object is updated.")
         help_text=_("Triggers when a matching object is updated.")
     )
     )
     type_delete = models.BooleanField(
     type_delete = models.BooleanField(
+        verbose_name=_('on delete'),
         default=False,
         default=False,
         help_text=_("Triggers when a matching object is deleted.")
         help_text=_("Triggers when a matching object is deleted.")
     )
     )
     type_job_start = models.BooleanField(
     type_job_start = models.BooleanField(
+        verbose_name=_('on job start'),
         default=False,
         default=False,
         help_text=_("Triggers when a job for a matching object is started.")
         help_text=_("Triggers when a job for a matching object is started.")
     )
     )
     type_job_end = models.BooleanField(
     type_job_end = models.BooleanField(
+        verbose_name=_('on job end'),
         default=False,
         default=False,
         help_text=_("Triggers when a job for a matching object terminates.")
         help_text=_("Triggers when a job for a matching object terminates.")
     )
     )
     payload_url = models.CharField(
     payload_url = models.CharField(
         max_length=500,
         max_length=500,
-        verbose_name='URL',
-        help_text=_('This URL will be called using the HTTP method defined when the webhook is called. '
-                    'Jinja2 template processing is supported with the same context as the request body.')
+        verbose_name=_('URL'),
+        help_text=_(
+            "This URL will be called using the HTTP method defined when the webhook is called. Jinja2 template "
+            "processing is supported with the same context as the request body."
+        )
     )
     )
     enabled = models.BooleanField(
     enabled = models.BooleanField(
+        verbose_name=_('enabled'),
         default=True
         default=True
     )
     )
     http_method = models.CharField(
     http_method = models.CharField(
         max_length=30,
         max_length=30,
         choices=WebhookHttpMethodChoices,
         choices=WebhookHttpMethodChoices,
         default=WebhookHttpMethodChoices.METHOD_POST,
         default=WebhookHttpMethodChoices.METHOD_POST,
-        verbose_name='HTTP method'
+        verbose_name=_('HTTP method')
     )
     )
     http_content_type = models.CharField(
     http_content_type = models.CharField(
         max_length=100,
         max_length=100,
         default=HTTP_CONTENT_TYPE_JSON,
         default=HTTP_CONTENT_TYPE_JSON,
-        verbose_name='HTTP content type',
-        help_text=_('The complete list of official content types is available '
-                    '<a href="https://www.iana.org/assignments/media-types/media-types.xhtml">here</a>.')
+        verbose_name=_('HTTP content type'),
+        help_text=_(
+            'The complete list of official content types is available '
+            '<a href="https://www.iana.org/assignments/media-types/media-types.xhtml">here</a>.'
+        )
     )
     )
     additional_headers = models.TextField(
     additional_headers = models.TextField(
+        verbose_name=_('additional headers'),
         blank=True,
         blank=True,
-        help_text=_("User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. "
-                    "Headers should be defined in the format <code>Name: Value</code>. Jinja2 template processing is "
-                    "supported with the same context as the request body (below).")
+        help_text=_(
+            "User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. Headers "
+            "should be defined in the format <code>Name: Value</code>. Jinja2 template processing is supported with "
+            "the same context as the request body (below)."
+        )
     )
     )
     body_template = models.TextField(
     body_template = models.TextField(
+        verbose_name=_('body template'),
         blank=True,
         blank=True,
-        help_text=_('Jinja2 template for a custom request body. If blank, a JSON object representing the change will be '
-                    'included. Available context data includes: <code>event</code>, <code>model</code>, '
-                    '<code>timestamp</code>, <code>username</code>, <code>request_id</code>, and <code>data</code>.')
+        help_text=_(
+            "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be "
+            "included. Available context data includes: <code>event</code>, <code>model</code>, "
+            "<code>timestamp</code>, <code>username</code>, <code>request_id</code>, and <code>data</code>."
+        )
     )
     )
     secret = models.CharField(
     secret = models.CharField(
+        verbose_name=_('secret'),
         max_length=255,
         max_length=255,
         blank=True,
         blank=True,
-        help_text=_("When provided, the request will include a 'X-Hook-Signature' "
-                    "header containing a HMAC hex digest of the payload body using "
-                    "the secret as the key. The secret is not transmitted in "
-                    "the request.")
+        help_text=_(
+            "When provided, the request will include a <code>X-Hook-Signature</code> header containing a HMAC hex "
+            "digest of the payload body using the secret as the key. The secret is not transmitted in the request."
+        )
     )
     )
     conditions = models.JSONField(
     conditions = models.JSONField(
+        verbose_name=_('conditions'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         help_text=_("A set of conditions which determine whether the webhook will be generated.")
         help_text=_("A set of conditions which determine whether the webhook will be generated.")
     )
     )
     ssl_verification = models.BooleanField(
     ssl_verification = models.BooleanField(
         default=True,
         default=True,
-        verbose_name='SSL verification',
+        verbose_name=_('SSL verification'),
         help_text=_("Enable SSL certificate verification. Disable with caution!")
         help_text=_("Enable SSL certificate verification. Disable with caution!")
     )
     )
     ca_file_path = models.CharField(
     ca_file_path = models.CharField(
         max_length=4096,
         max_length=4096,
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='CA File Path',
-        help_text=_('The specific CA certificate file to use for SSL verification. '
-                    'Leave blank to use the system defaults.')
+        verbose_name=_('CA File Path'),
+        help_text=_(
+            "The specific CA certificate file to use for SSL verification. Leave blank to use the system defaults."
+        )
     )
     )
 
 
     class Meta:
     class Meta:
@@ -164,7 +184,7 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
             self.type_create, self.type_update, self.type_delete, self.type_job_start, self.type_job_end
             self.type_create, self.type_update, self.type_delete, self.type_job_start, self.type_job_end
         ]):
         ]):
             raise ValidationError(
             raise ValidationError(
-                "At least one event type must be selected: create, update, delete, job_start, and/or job_end."
+                _("At least one event type must be selected: create, update, delete, job_start, and/or job_end.")
             )
             )
 
 
         if self.conditions:
         if self.conditions:
@@ -176,7 +196,7 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
         # CA file path requires SSL verification enabled
         # CA file path requires SSL verification enabled
         if not self.ssl_verification and self.ca_file_path:
         if not self.ssl_verification and self.ca_file_path:
             raise ValidationError({
             raise ValidationError({
-                'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
+                'ca_file_path': _('Do not specify a CA certificate file if SSL verification is disabled.')
             })
             })
 
 
     def render_headers(self, context):
     def render_headers(self, context):
@@ -219,34 +239,41 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         help_text=_('The object type(s) to which this link applies.')
         help_text=_('The object type(s) to which this link applies.')
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     enabled = models.BooleanField(
     enabled = models.BooleanField(
+        verbose_name=_('enabled'),
         default=True
         default=True
     )
     )
     link_text = models.TextField(
     link_text = models.TextField(
+        verbose_name=_('link text'),
         help_text=_("Jinja2 template code for link text")
         help_text=_("Jinja2 template code for link text")
     )
     )
     link_url = models.TextField(
     link_url = models.TextField(
-        verbose_name='Link URL',
+        verbose_name=_('link URL'),
         help_text=_("Jinja2 template code for link URL")
         help_text=_("Jinja2 template code for link URL")
     )
     )
     weight = models.PositiveSmallIntegerField(
     weight = models.PositiveSmallIntegerField(
+        verbose_name=_('weight'),
         default=100
         default=100
     )
     )
     group_name = models.CharField(
     group_name = models.CharField(
+        verbose_name=_('group name'),
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         help_text=_("Links with the same group will appear as a dropdown menu")
         help_text=_("Links with the same group will appear as a dropdown menu")
     )
     )
     button_class = models.CharField(
     button_class = models.CharField(
+        verbose_name=_('button class'),
         max_length=30,
         max_length=30,
         choices=CustomLinkButtonClassChoices,
         choices=CustomLinkButtonClassChoices,
         default=CustomLinkButtonClassChoices.DEFAULT,
         default=CustomLinkButtonClassChoices.DEFAULT,
         help_text=_("The class of the first link in a group will be used for the dropdown button")
         help_text=_("The class of the first link in a group will be used for the dropdown button")
     )
     )
     new_window = models.BooleanField(
     new_window = models.BooleanField(
+        verbose_name=_('new window'),
         default=False,
         default=False,
         help_text=_("Force link to open in a new window")
         help_text=_("Force link to open in a new window")
     )
     )
@@ -306,28 +333,34 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
         help_text=_('The object type(s) to which this template applies.')
         help_text=_('The object type(s) to which this template applies.')
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
     template_code = models.TextField(
     template_code = models.TextField(
-        help_text=_('Jinja2 template code. The list of objects being exported is passed as a context variable named '
-                    '<code>queryset</code>.')
+        help_text=_(
+            "Jinja2 template code. The list of objects being exported is passed as a context variable named "
+            "<code>queryset</code>."
+        )
     )
     )
     mime_type = models.CharField(
     mime_type = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
-        verbose_name='MIME type',
+        verbose_name=_('MIME type'),
         help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
         help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
     )
     )
     file_extension = models.CharField(
     file_extension = models.CharField(
+        verbose_name=_('file extension'),
         max_length=15,
         max_length=15,
         blank=True,
         blank=True,
         help_text=_('Extension to append to the rendered filename')
         help_text=_('Extension to append to the rendered filename')
     )
     )
     as_attachment = models.BooleanField(
     as_attachment = models.BooleanField(
+        verbose_name=_('as attachment'),
         default=True,
         default=True,
         help_text=_("Download file as attachment")
         help_text=_("Download file as attachment")
     )
     )
@@ -354,7 +387,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
 
 
         if self.name.lower() == 'table':
         if self.name.lower() == 'table':
             raise ValidationError({
             raise ValidationError({
-                'name': f'"{self.name}" is a reserved name. Please choose a different name.'
+                'name': _('"{name}" is a reserved name. Please choose a different name.').format(name=self.name)
             })
             })
 
 
     def sync_data(self):
     def sync_data(self):
@@ -407,14 +440,17 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         help_text=_('The object type(s) to which this filter applies.')
         help_text=_('The object type(s) to which this filter applies.')
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
@@ -425,15 +461,20 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         null=True
         null=True
     )
     )
     weight = models.PositiveSmallIntegerField(
     weight = models.PositiveSmallIntegerField(
+        verbose_name=_('weight'),
         default=100
         default=100
     )
     )
     enabled = models.BooleanField(
     enabled = models.BooleanField(
+        verbose_name=_('enabled'),
         default=True
         default=True
     )
     )
     shared = models.BooleanField(
     shared = models.BooleanField(
+        verbose_name=_('shared'),
         default=True
         default=True
     )
     )
-    parameters = models.JSONField()
+    parameters = models.JSONField(
+        verbose_name=_('parameters')
+    )
 
 
     clone_fields = (
     clone_fields = (
         'content_types', 'weight', 'enabled', 'parameters',
         'content_types', 'weight', 'enabled', 'parameters',
@@ -458,7 +499,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         # Verify that `parameters` is a JSON object
         # Verify that `parameters` is a JSON object
         if type(self.parameters) is not dict:
         if type(self.parameters) is not dict:
             raise ValidationError(
             raise ValidationError(
-                {'parameters': 'Filter parameters must be stored as a dictionary of keyword arguments.'}
+                {'parameters': _('Filter parameters must be stored as a dictionary of keyword arguments.')}
             )
             )
 
 
     @property
     @property
@@ -485,9 +526,14 @@ class ImageAttachment(ChangeLoggedModel):
         height_field='image_height',
         height_field='image_height',
         width_field='image_width'
         width_field='image_width'
     )
     )
-    image_height = models.PositiveSmallIntegerField()
-    image_width = models.PositiveSmallIntegerField()
+    image_height = models.PositiveSmallIntegerField(
+        verbose_name=_('image height'),
+    )
+    image_width = models.PositiveSmallIntegerField(
+        verbose_name=_('image width'),
+    )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=50,
         max_length=50,
         blank=True
         blank=True
     )
     )
@@ -565,11 +611,14 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
         null=True
         null=True
     )
     )
     kind = models.CharField(
     kind = models.CharField(
+        verbose_name=_('kind'),
         max_length=30,
         max_length=30,
         choices=JournalEntryKindChoices,
         choices=JournalEntryKindChoices,
         default=JournalEntryKindChoices.KIND_INFO
         default=JournalEntryKindChoices.KIND_INFO
     )
     )
-    comments = models.TextField()
+    comments = models.TextField(
+        verbose_name=_('comments'),
+    )
 
 
     class Meta:
     class Meta:
         ordering = ('-created',)
         ordering = ('-created',)
@@ -588,7 +637,9 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
         # Prevent the creation of journal entries on unsupported models
         # Prevent the creation of journal entries on unsupported models
         permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query())
         permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query())
         if self.assigned_object_type not in permitted_types:
         if self.assigned_object_type not in permitted_types:
-            raise ValidationError(f"Journaling is not supported for this object type ({self.assigned_object_type}).")
+            raise ValidationError(
+                _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type)
+            )
 
 
     def get_kind_color(self):
     def get_kind_color(self):
         return JournalEntryKindChoices.colors.get(self.kind)
         return JournalEntryKindChoices.colors.get(self.kind)
@@ -599,6 +650,7 @@ class Bookmark(models.Model):
     An object bookmarked by a User.
     An object bookmarked by a User.
     """
     """
     created = models.DateTimeField(
     created = models.DateTimeField(
+        verbose_name=_('created'),
         auto_now_add=True
         auto_now_add=True
     )
     )
     object_type = models.ForeignKey(
     object_type = models.ForeignKey(
@@ -637,16 +689,18 @@ class ConfigRevision(models.Model):
     An atomic revision of NetBox's configuration.
     An atomic revision of NetBox's configuration.
     """
     """
     created = models.DateTimeField(
     created = models.DateTimeField(
+        verbose_name=_('created'),
         auto_now_add=True
         auto_now_add=True
     )
     )
     comment = models.CharField(
     comment = models.CharField(
+        verbose_name=_('comment'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
     data = models.JSONField(
     data = models.JSONField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Configuration data'
+        verbose_name=_('configuration data')
     )
     )
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()

+ 8 - 1
netbox/extras/models/search.py

@@ -2,6 +2,7 @@ import uuid
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.db import models
+from django.utils.translation import gettext_lazy as _
 
 
 from utilities.fields import RestrictedGenericForeignKey
 from utilities.fields import RestrictedGenericForeignKey
 from ..fields import CachedValueField
 from ..fields import CachedValueField
@@ -18,6 +19,7 @@ class CachedValue(models.Model):
         editable=False
         editable=False
     )
     )
     timestamp = models.DateTimeField(
     timestamp = models.DateTimeField(
+        verbose_name=_('timestamp'),
         auto_now_add=True,
         auto_now_add=True,
         editable=False
         editable=False
     )
     )
@@ -32,13 +34,18 @@ class CachedValue(models.Model):
         fk_field='object_id'
         fk_field='object_id'
     )
     )
     field = models.CharField(
     field = models.CharField(
+        verbose_name=_('field'),
         max_length=200
         max_length=200
     )
     )
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=30
         max_length=30
     )
     )
-    value = CachedValueField()
+    value = CachedValueField(
+        verbose_name=_('value'),
+    )
     weight = models.PositiveSmallIntegerField(
     weight = models.PositiveSmallIntegerField(
+        verbose_name=_('weight'),
         default=1000
         default=1000
     )
     )
 
 

+ 5 - 0
netbox/extras/models/staging.py

@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db import models, transaction
 from django.db import models, transaction
+from django.utils.translation import gettext_lazy as _
 
 
 from extras.choices import ChangeActionChoices
 from extras.choices import ChangeActionChoices
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
@@ -22,10 +23,12 @@ class Branch(ChangeLoggedModel):
     A collection of related StagedChanges.
     A collection of related StagedChanges.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
@@ -61,6 +64,7 @@ class StagedChange(ChangeLoggedModel):
         related_name='staged_changes'
         related_name='staged_changes'
     )
     )
     action = models.CharField(
     action = models.CharField(
+        verbose_name=_('action'),
         max_length=20,
         max_length=20,
         choices=ChangeActionChoices
         choices=ChangeActionChoices
     )
     )
@@ -78,6 +82,7 @@ class StagedChange(ChangeLoggedModel):
         fk_field='object_id'
         fk_field='object_id'
     )
     )
     data = models.JSONField(
     data = models.JSONField(
+        verbose_name=_('data'),
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )

+ 3 - 1
netbox/extras/models/tags.py

@@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.text import slugify
 from django.utils.text import slugify
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 from taggit.models import TagBase, GenericTaggedItemBase
 from taggit.models import TagBase, GenericTaggedItemBase
 
 
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
@@ -28,9 +28,11 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
         primary_key=True
         primary_key=True
     )
     )
     color = ColorField(
     color = ColorField(
+        verbose_name=_('color'),
         default=ColorChoices.COLOR_GREY
         default=ColorChoices.COLOR_GREY
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True,
         blank=True,
     )
     )

+ 17 - 7
netbox/ipam/models/asns.py

@@ -1,7 +1,7 @@
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 
 from ipam.fields import ASNField
 from ipam.fields import ASNField
 from ipam.querysets import ASNRangeQuerySet
 from ipam.querysets import ASNRangeQuerySet
@@ -15,10 +15,12 @@ __all__ = (
 
 
 class ASNRange(OrganizationalModel):
 class ASNRange(OrganizationalModel):
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
@@ -26,10 +28,14 @@ class ASNRange(OrganizationalModel):
         to='ipam.RIR',
         to='ipam.RIR',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='asn_ranges',
         related_name='asn_ranges',
-        verbose_name='RIR'
+        verbose_name=_('RIR')
+    )
+    start = ASNField(
+        verbose_name=_('start'),
+    )
+    end = ASNField(
+        verbose_name=_('end'),
     )
     )
-    start = ASNField()
-    end = ASNField()
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -62,7 +68,11 @@ class ASNRange(OrganizationalModel):
         super().clean()
         super().clean()
 
 
         if self.end <= self.start:
         if self.end <= self.start:
-            raise ValidationError(f"Starting ASN ({self.start}) must be lower than ending ASN ({self.end}).")
+            raise ValidationError(
+                _("Starting ASN ({start}) must be lower than ending ASN ({end}).").format(
+                    start=self.start, end=self.end
+                )
+            )
 
 
     def get_child_asns(self):
     def get_child_asns(self):
         return ASN.objects.filter(
         return ASN.objects.filter(
@@ -90,12 +100,12 @@ class ASN(PrimaryModel):
         to='ipam.RIR',
         to='ipam.RIR',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='asns',
         related_name='asns',
-        verbose_name='RIR',
+        verbose_name=_('RIR'),
         help_text=_("Regional Internet Registry responsible for this AS number space")
         help_text=_("Regional Internet Registry responsible for this AS number space")
     )
     )
     asn = ASNField(
     asn = ASNField(
         unique=True,
         unique=True,
-        verbose_name='ASN',
+        verbose_name=_('ASN'),
         help_text=_('16- or 32-bit autonomous system number')
         help_text=_('16- or 32-bit autonomous system number')
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(

+ 8 - 4
netbox/ipam/models/fhrp.py

@@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
 
 
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from ipam.choices import *
 from ipam.choices import *
@@ -19,13 +20,15 @@ class FHRPGroup(PrimaryModel):
     A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.)
     A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.)
     """
     """
     group_id = models.PositiveSmallIntegerField(
     group_id = models.PositiveSmallIntegerField(
-        verbose_name='Group ID'
+        verbose_name=_('group ID')
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         blank=True
         blank=True
     )
     )
     protocol = models.CharField(
     protocol = models.CharField(
+        verbose_name=_('protocol'),
         max_length=50,
         max_length=50,
         choices=FHRPGroupProtocolChoices
         choices=FHRPGroupProtocolChoices
     )
     )
@@ -33,12 +36,12 @@ class FHRPGroup(PrimaryModel):
         max_length=50,
         max_length=50,
         choices=FHRPGroupAuthTypeChoices,
         choices=FHRPGroupAuthTypeChoices,
         blank=True,
         blank=True,
-        verbose_name='Authentication type'
+        verbose_name=_('authentication type')
     )
     )
     auth_key = models.CharField(
     auth_key = models.CharField(
         max_length=255,
         max_length=255,
         blank=True,
         blank=True,
-        verbose_name='Authentication key'
+        verbose_name=_('authentication key')
     )
     )
     ip_addresses = GenericRelation(
     ip_addresses = GenericRelation(
         to='ipam.IPAddress',
         to='ipam.IPAddress',
@@ -87,6 +90,7 @@ class FHRPGroupAssignment(ChangeLoggedModel):
         on_delete=models.CASCADE
         on_delete=models.CASCADE
     )
     )
     priority = models.PositiveSmallIntegerField(
     priority = models.PositiveSmallIntegerField(
+        verbose_name=_('priority'),
         validators=(
         validators=(
             MinValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MIN),
             MinValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MIN),
             MaxValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MAX)
             MaxValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MAX)
@@ -103,7 +107,7 @@ class FHRPGroupAssignment(ChangeLoggedModel):
                 name='%(app_label)s_%(class)s_unique_interface_group'
                 name='%(app_label)s_%(class)s_unique_interface_group'
             ),
             ),
         )
         )
-        verbose_name = 'FHRP group assignment'
+        verbose_name = _('FHRP group assignment')
 
 
     def __str__(self):
     def __str__(self):
         return f'{self.interface}: {self.group} ({self.priority})'
         return f'{self.interface}: {self.group} ({self.priority})'

+ 62 - 33
netbox/ipam/models/ip.py

@@ -6,7 +6,7 @@ from django.db import models
 from django.db.models import F
 from django.db.models import F
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.functional import cached_property
 from django.utils.functional import cached_property
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
@@ -59,14 +59,14 @@ class RIR(OrganizationalModel):
     """
     """
     is_private = models.BooleanField(
     is_private = models.BooleanField(
         default=False,
         default=False,
-        verbose_name='Private',
+        verbose_name=_('private'),
         help_text=_('IP space managed by this RIR is considered private')
         help_text=_('IP space managed by this RIR is considered private')
     )
     )
 
 
     class Meta:
     class Meta:
         ordering = ('name',)
         ordering = ('name',)
-        verbose_name = 'RIR'
-        verbose_name_plural = 'RIRs'
+        verbose_name = _('RIR')
+        verbose_name_plural = _('RIRs')
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('ipam:rir', args=[self.pk])
         return reverse('ipam:rir', args=[self.pk])
@@ -84,7 +84,7 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
         to='ipam.RIR',
         to='ipam.RIR',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='aggregates',
         related_name='aggregates',
-        verbose_name='RIR',
+        verbose_name=_('RIR'),
         help_text=_("Regional Internet Registry responsible for this IP space")
         help_text=_("Regional Internet Registry responsible for this IP space")
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
@@ -95,6 +95,7 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
         null=True
         null=True
     )
     )
     date_added = models.DateField(
     date_added = models.DateField(
+        verbose_name=_('date added'),
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
@@ -123,7 +124,7 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
             # /0 masks are not acceptable
             # /0 masks are not acceptable
             if self.prefix.prefixlen == 0:
             if self.prefix.prefixlen == 0:
                 raise ValidationError({
                 raise ValidationError({
-                    'prefix': "Cannot create aggregate with /0 mask."
+                    'prefix': _("Cannot create aggregate with /0 mask.")
                 })
                 })
 
 
             # Ensure that the aggregate being added is not covered by an existing aggregate
             # Ensure that the aggregate being added is not covered by an existing aggregate
@@ -134,9 +135,9 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
                 covering_aggregates = covering_aggregates.exclude(pk=self.pk)
                 covering_aggregates = covering_aggregates.exclude(pk=self.pk)
             if covering_aggregates:
             if covering_aggregates:
                 raise ValidationError({
                 raise ValidationError({
-                    'prefix': "Aggregates cannot overlap. {} is already covered by an existing aggregate ({}).".format(
-                        self.prefix, covering_aggregates[0]
-                    )
+                    'prefix': _(
+                        "Aggregates cannot overlap. {} is already covered by an existing aggregate ({})."
+                    ).format(self.prefix, covering_aggregates[0])
                 })
                 })
 
 
             # Ensure that the aggregate being added does not cover an existing aggregate
             # Ensure that the aggregate being added does not cover an existing aggregate
@@ -145,7 +146,7 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
                 covered_aggregates = covered_aggregates.exclude(pk=self.pk)
                 covered_aggregates = covered_aggregates.exclude(pk=self.pk)
             if covered_aggregates:
             if covered_aggregates:
                 raise ValidationError({
                 raise ValidationError({
-                    'prefix': "Aggregates cannot overlap. {} covers an existing aggregate ({}).".format(
+                    'prefix': _("Aggregates cannot overlap. {} covers an existing aggregate ({}).").format(
                         self.prefix, covered_aggregates[0]
                         self.prefix, covered_aggregates[0]
                     )
                     )
                 })
                 })
@@ -179,6 +180,7 @@ class Role(OrganizationalModel):
     "Management."
     "Management."
     """
     """
     weight = models.PositiveSmallIntegerField(
     weight = models.PositiveSmallIntegerField(
+        verbose_name=_('weight'),
         default=1000
         default=1000
     )
     )
 
 
@@ -199,6 +201,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
     assigned to a VLAN where appropriate.
     assigned to a VLAN where appropriate.
     """
     """
     prefix = IPNetworkField(
     prefix = IPNetworkField(
+        verbose_name=_('prefix'),
         help_text=_('IPv4 or IPv6 network with mask')
         help_text=_('IPv4 or IPv6 network with mask')
     )
     )
     site = models.ForeignKey(
     site = models.ForeignKey(
@@ -214,7 +217,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
         related_name='prefixes',
         related_name='prefixes',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='VRF'
+        verbose_name=_('VRF')
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
@@ -228,14 +231,13 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='prefixes',
         related_name='prefixes',
         blank=True,
         blank=True,
-        null=True,
-        verbose_name='VLAN'
+        null=True
     )
     )
     status = models.CharField(
     status = models.CharField(
         max_length=50,
         max_length=50,
         choices=PrefixStatusChoices,
         choices=PrefixStatusChoices,
         default=PrefixStatusChoices.STATUS_ACTIVE,
         default=PrefixStatusChoices.STATUS_ACTIVE,
-        verbose_name='Status',
+        verbose_name=_('status'),
         help_text=_('Operational status of this prefix')
         help_text=_('Operational status of this prefix')
     )
     )
     role = models.ForeignKey(
     role = models.ForeignKey(
@@ -247,11 +249,12 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
         help_text=_('The primary function of this prefix')
         help_text=_('The primary function of this prefix')
     )
     )
     is_pool = models.BooleanField(
     is_pool = models.BooleanField(
-        verbose_name='Is a pool',
+        verbose_name=_('is a pool'),
         default=False,
         default=False,
         help_text=_('All IP addresses within this prefix are considered usable')
         help_text=_('All IP addresses within this prefix are considered usable')
     )
     )
     mark_utilized = models.BooleanField(
     mark_utilized = models.BooleanField(
+        verbose_name=_('mark utilized'),
         default=False,
         default=False,
         help_text=_("Treat as 100% utilized")
         help_text=_("Treat as 100% utilized")
     )
     )
@@ -297,7 +300,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
             # /0 masks are not acceptable
             # /0 masks are not acceptable
             if self.prefix.prefixlen == 0:
             if self.prefix.prefixlen == 0:
                 raise ValidationError({
                 raise ValidationError({
-                    'prefix': "Cannot create prefix with /0 mask."
+                    'prefix': _("Cannot create prefix with /0 mask.")
                 })
                 })
 
 
             # Enforce unique IP space (if applicable)
             # Enforce unique IP space (if applicable)
@@ -305,8 +308,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
                 duplicate_prefixes = self.get_duplicates()
                 duplicate_prefixes = self.get_duplicates()
                 if duplicate_prefixes:
                 if duplicate_prefixes:
                     raise ValidationError({
                     raise ValidationError({
-                        'prefix': "Duplicate prefix found in {}: {}".format(
-                            "VRF {}".format(self.vrf) if self.vrf else "global table",
+                        'prefix': _("Duplicate prefix found in {}: {}").format(
+                            _("VRF {}").format(self.vrf) if self.vrf else _("global table"),
                             duplicate_prefixes.first(),
                             duplicate_prefixes.first(),
                         )
                         )
                     })
                     })
@@ -474,12 +477,15 @@ class IPRange(PrimaryModel):
     A range of IP addresses, defined by start and end addresses.
     A range of IP addresses, defined by start and end addresses.
     """
     """
     start_address = IPAddressField(
     start_address = IPAddressField(
+        verbose_name=_('start address'),
         help_text=_('IPv4 or IPv6 address (with mask)')
         help_text=_('IPv4 or IPv6 address (with mask)')
     )
     )
     end_address = IPAddressField(
     end_address = IPAddressField(
+        verbose_name=_('end address'),
         help_text=_('IPv4 or IPv6 address (with mask)')
         help_text=_('IPv4 or IPv6 address (with mask)')
     )
     )
     size = models.PositiveIntegerField(
     size = models.PositiveIntegerField(
+        verbose_name=_('size'),
         editable=False
         editable=False
     )
     )
     vrf = models.ForeignKey(
     vrf = models.ForeignKey(
@@ -488,7 +494,7 @@ class IPRange(PrimaryModel):
         related_name='ip_ranges',
         related_name='ip_ranges',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='VRF'
+        verbose_name=_('VRF')
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
@@ -498,6 +504,7 @@ class IPRange(PrimaryModel):
         null=True
         null=True
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=IPRangeStatusChoices,
         choices=IPRangeStatusChoices,
         default=IPRangeStatusChoices.STATUS_ACTIVE,
         default=IPRangeStatusChoices.STATUS_ACTIVE,
@@ -512,6 +519,7 @@ class IPRange(PrimaryModel):
         help_text=_('The primary function of this range')
         help_text=_('The primary function of this range')
     )
     )
     mark_utilized = models.BooleanField(
     mark_utilized = models.BooleanField(
+        verbose_name=_('mark utilized'),
         default=False,
         default=False,
         help_text=_("Treat as 100% utilized")
         help_text=_("Treat as 100% utilized")
     )
     )
@@ -539,21 +547,33 @@ class IPRange(PrimaryModel):
             # Check that start & end IP versions match
             # Check that start & end IP versions match
             if self.start_address.version != self.end_address.version:
             if self.start_address.version != self.end_address.version:
                 raise ValidationError({
                 raise ValidationError({
-                    'end_address': f"Ending address version (IPv{self.end_address.version}) does not match starting "
-                                   f"address (IPv{self.start_address.version})"
+                    'end_address': _(
+                        "Ending address version (IPv{end_address_version}) does not match starting address "
+                        "(IPv{start_address_version})"
+                    ).format(
+                        end_address_version=self.end_address.version,
+                        start_address_version=self.start_address.version
+                    )
                 })
                 })
 
 
             # Check that the start & end IP prefix lengths match
             # Check that the start & end IP prefix lengths match
             if self.start_address.prefixlen != self.end_address.prefixlen:
             if self.start_address.prefixlen != self.end_address.prefixlen:
                 raise ValidationError({
                 raise ValidationError({
-                    'end_address': f"Ending address mask (/{self.end_address.prefixlen}) does not match starting "
-                                   f"address mask (/{self.start_address.prefixlen})"
+                    'end_address': _(
+                        "Ending address mask (/{end_address_prefixlen}) does not match starting address mask "
+                        "(/{start_address_prefixlen})"
+                    ).format(
+                        end_address_prefixlen=self.end_address.prefixlen,
+                        start_address_prefixlen=self.start_address.prefixlen
+                    )
                 })
                 })
 
 
             # Check that the ending address is greater than the starting address
             # Check that the ending address is greater than the starting address
             if not self.end_address > self.start_address:
             if not self.end_address > self.start_address:
                 raise ValidationError({
                 raise ValidationError({
-                    'end_address': f"Ending address must be lower than the starting address ({self.start_address})"
+                    'end_address': _(
+                        "Ending address must be lower than the starting address ({start_address})"
+                    ).format(start_address=self.start_address)
                 })
                 })
 
 
             # Check for overlapping ranges
             # Check for overlapping ranges
@@ -563,12 +583,18 @@ class IPRange(PrimaryModel):
                 Q(start_address__lte=self.start_address, end_address__gte=self.end_address)  # Starts & ends outside
                 Q(start_address__lte=self.start_address, end_address__gte=self.end_address)  # Starts & ends outside
             ).first()
             ).first()
             if overlapping_range:
             if overlapping_range:
-                raise ValidationError(f"Defined addresses overlap with range {overlapping_range} in VRF {self.vrf}")
+                raise ValidationError(
+                    _("Defined addresses overlap with range {overlapping_range} in VRF {vrf}").format(
+                        overlapping_range=overlapping_range,
+                        vrf=self.vrf
+                    ))
 
 
             # Validate maximum size
             # Validate maximum size
             MAX_SIZE = 2 ** 32 - 1
             MAX_SIZE = 2 ** 32 - 1
             if int(self.end_address.ip - self.start_address.ip) + 1 > MAX_SIZE:
             if int(self.end_address.ip - self.start_address.ip) + 1 > MAX_SIZE:
-                raise ValidationError(f"Defined range exceeds maximum supported size ({MAX_SIZE})")
+                raise ValidationError(
+                    _("Defined range exceeds maximum supported size ({max_size})").format(max_size=MAX_SIZE)
+                )
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
@@ -679,6 +705,7 @@ class IPAddress(PrimaryModel):
     which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
     which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
     """
     """
     address = IPAddressField(
     address = IPAddressField(
+        verbose_name=_('address'),
         help_text=_('IPv4 or IPv6 address (with mask)')
         help_text=_('IPv4 or IPv6 address (with mask)')
     )
     )
     vrf = models.ForeignKey(
     vrf = models.ForeignKey(
@@ -687,7 +714,7 @@ class IPAddress(PrimaryModel):
         related_name='ip_addresses',
         related_name='ip_addresses',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='VRF'
+        verbose_name=_('VRF')
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
@@ -697,12 +724,14 @@ class IPAddress(PrimaryModel):
         null=True
         null=True
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=IPAddressStatusChoices,
         choices=IPAddressStatusChoices,
         default=IPAddressStatusChoices.STATUS_ACTIVE,
         default=IPAddressStatusChoices.STATUS_ACTIVE,
         help_text=_('The operational status of this IP')
         help_text=_('The operational status of this IP')
     )
     )
     role = models.CharField(
     role = models.CharField(
+        verbose_name=_('role'),
         max_length=50,
         max_length=50,
         choices=IPAddressRoleChoices,
         choices=IPAddressRoleChoices,
         blank=True,
         blank=True,
@@ -730,14 +759,14 @@ class IPAddress(PrimaryModel):
         related_name='nat_outside',
         related_name='nat_outside',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='NAT (Inside)',
+        verbose_name=_('NAT (inside)'),
         help_text=_('The IP for which this address is the "outside" IP')
         help_text=_('The IP for which this address is the "outside" IP')
     )
     )
     dns_name = models.CharField(
     dns_name = models.CharField(
         max_length=255,
         max_length=255,
         blank=True,
         blank=True,
         validators=[DNSValidator],
         validators=[DNSValidator],
-        verbose_name='DNS Name',
+        verbose_name=_('DNS name'),
         help_text=_('Hostname or FQDN (not case-sensitive)')
         help_text=_('Hostname or FQDN (not case-sensitive)')
     )
     )
 
 
@@ -799,7 +828,7 @@ class IPAddress(PrimaryModel):
             # /0 masks are not acceptable
             # /0 masks are not acceptable
             if self.address.prefixlen == 0:
             if self.address.prefixlen == 0:
                 raise ValidationError({
                 raise ValidationError({
-                    'address': "Cannot create IP address with /0 mask."
+                    'address': _("Cannot create IP address with /0 mask.")
                 })
                 })
 
 
             # Enforce unique IP space (if applicable)
             # Enforce unique IP space (if applicable)
@@ -810,8 +839,8 @@ class IPAddress(PrimaryModel):
                         any(dip.role not in IPADDRESS_ROLES_NONUNIQUE for dip in duplicate_ips)
                         any(dip.role not in IPADDRESS_ROLES_NONUNIQUE for dip in duplicate_ips)
                 ):
                 ):
                     raise ValidationError({
                     raise ValidationError({
-                        'address': "Duplicate IP address found in {}: {}".format(
-                            "VRF {}".format(self.vrf) if self.vrf else "global table",
+                        'address': _("Duplicate IP address found in {}: {}").format(
+                            _("VRF {}").format(self.vrf) if self.vrf else _("global table"),
                             duplicate_ips.first(),
                             duplicate_ips.first(),
                         )
                         )
                     })
                     })
@@ -819,7 +848,7 @@ class IPAddress(PrimaryModel):
         # Validate IP status selection
         # Validate IP status selection
         if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
         if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
             raise ValidationError({
             raise ValidationError({
-                'status': "Only IPv6 addresses can be assigned SLAAC status"
+                'status': _("Only IPv6 addresses can be assigned SLAAC status")
             })
             })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):

+ 14 - 4
netbox/ipam/models/l2vpn.py

@@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.functional import cached_property
 from django.utils.functional import cached_property
+from django.utils.translation import gettext_lazy as _
 
 
 from ipam.choices import L2VPNTypeChoices
 from ipam.choices import L2VPNTypeChoices
 from ipam.constants import L2VPN_ASSIGNMENT_MODELS
 from ipam.constants import L2VPN_ASSIGNMENT_MODELS
@@ -17,18 +18,22 @@ __all__ = (
 
 
 class L2VPN(PrimaryModel):
 class L2VPN(PrimaryModel):
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     type = models.CharField(
     type = models.CharField(
+        verbose_name=_('type'),
         max_length=50,
         max_length=50,
         choices=L2VPNTypeChoices
         choices=L2VPNTypeChoices
     )
     )
     identifier = models.BigIntegerField(
     identifier = models.BigIntegerField(
+        verbose_name=_('identifier'),
         null=True,
         null=True,
         blank=True
         blank=True
     )
     )
@@ -123,7 +128,11 @@ class L2VPNTermination(NetBoxModel):
             obj_type = ContentType.objects.get_for_model(self.assigned_object)
             obj_type = ContentType.objects.get_for_model(self.assigned_object)
             if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\
             if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\
                     exclude(pk=self.pk).count() > 0:
                     exclude(pk=self.pk).count() > 0:
-                raise ValidationError(f'L2VPN Termination already assigned ({self.assigned_object})')
+                raise ValidationError(
+                    _('L2VPN Termination already assigned ({assigned_object})').format(
+                        assigned_object=self.assigned_object
+                    )
+                )
 
 
         # Only check if L2VPN is set and is of type P2P
         # Only check if L2VPN is set and is of type P2P
         if hasattr(self, 'l2vpn') and self.l2vpn.type in L2VPNTypeChoices.P2P:
         if hasattr(self, 'l2vpn') and self.l2vpn.type in L2VPNTypeChoices.P2P:
@@ -131,9 +140,10 @@ class L2VPNTermination(NetBoxModel):
             if terminations_count >= 2:
             if terminations_count >= 2:
                 l2vpn_type = self.l2vpn.get_type_display()
                 l2vpn_type = self.l2vpn.get_type_display()
                 raise ValidationError(
                 raise ValidationError(
-                    f'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already '
-                    f'defined.'
-                )
+                    _(
+                        '{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} '
+                        'already defined.'
+                    ).format(l2vpn_type=l2vpn_type, terminations_count=terminations_count))
 
 
     @property
     @property
     def assigned_object_parent(self):
     def assigned_object_parent(self):

+ 10 - 7
netbox/ipam/models/services.py

@@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
@@ -19,6 +19,7 @@ __all__ = (
 
 
 class ServiceBase(models.Model):
 class ServiceBase(models.Model):
     protocol = models.CharField(
     protocol = models.CharField(
+        verbose_name=_('protocol'),
         max_length=50,
         max_length=50,
         choices=ServiceProtocolChoices
         choices=ServiceProtocolChoices
     )
     )
@@ -29,7 +30,7 @@ class ServiceBase(models.Model):
                 MaxValueValidator(SERVICE_PORT_MAX)
                 MaxValueValidator(SERVICE_PORT_MAX)
             ]
             ]
         ),
         ),
-        verbose_name='Port numbers'
+        verbose_name=_('port numbers')
     )
     )
 
 
     class Meta:
     class Meta:
@@ -48,6 +49,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
     A template for a Service to be applied to a device or virtual machine.
     A template for a Service to be applied to a device or virtual machine.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
@@ -68,7 +70,7 @@ class Service(ServiceBase, PrimaryModel):
         to='dcim.Device',
         to='dcim.Device',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         related_name='services',
         related_name='services',
-        verbose_name='device',
+        verbose_name=_('device'),
         null=True,
         null=True,
         blank=True
         blank=True
     )
     )
@@ -80,13 +82,14 @@ class Service(ServiceBase, PrimaryModel):
         blank=True
         blank=True
     )
     )
     name = models.CharField(
     name = models.CharField(
-        max_length=100
+        max_length=100,
+        verbose_name=_('name')
     )
     )
     ipaddresses = models.ManyToManyField(
     ipaddresses = models.ManyToManyField(
         to='ipam.IPAddress',
         to='ipam.IPAddress',
         related_name='services',
         related_name='services',
         blank=True,
         blank=True,
-        verbose_name='IP addresses',
+        verbose_name=_('IP addresses'),
         help_text=_("The specific IP addresses (if any) to which this service is bound")
         help_text=_("The specific IP addresses (if any) to which this service is bound")
     )
     )
 
 
@@ -107,6 +110,6 @@ class Service(ServiceBase, PrimaryModel):
 
 
         # A Service must belong to a Device *or* to a VirtualMachine
         # A Service must belong to a Device *or* to a VirtualMachine
         if self.device and self.virtual_machine:
         if self.device and self.virtual_machine:
-            raise ValidationError("A service cannot be associated with both a device and a virtual machine.")
+            raise ValidationError(_("A service cannot be associated with both a device and a virtual machine."))
         if not self.device and not self.virtual_machine:
         if not self.device and not self.virtual_machine:
-            raise ValidationError("A service must be associated with either a device or a virtual machine.")
+            raise ValidationError(_("A service must be associated with either a device or a virtual machine."))

+ 17 - 11
netbox/ipam/models/vlans.py

@@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import Interface
 from dcim.models import Interface
 from ipam.choices import *
 from ipam.choices import *
@@ -24,9 +24,11 @@ class VLANGroup(OrganizationalModel):
     A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
     A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100
         max_length=100
     )
     )
     scope_type = models.ForeignKey(
     scope_type = models.ForeignKey(
@@ -45,7 +47,7 @@ class VLANGroup(OrganizationalModel):
         fk_field='scope_id'
         fk_field='scope_id'
     )
     )
     min_vid = models.PositiveSmallIntegerField(
     min_vid = models.PositiveSmallIntegerField(
-        verbose_name='Minimum VLAN ID',
+        verbose_name=_('minimum VLAN ID'),
         default=VLAN_VID_MIN,
         default=VLAN_VID_MIN,
         validators=(
         validators=(
             MinValueValidator(VLAN_VID_MIN),
             MinValueValidator(VLAN_VID_MIN),
@@ -54,7 +56,7 @@ class VLANGroup(OrganizationalModel):
         help_text=_('Lowest permissible ID of a child VLAN')
         help_text=_('Lowest permissible ID of a child VLAN')
     )
     )
     max_vid = models.PositiveSmallIntegerField(
     max_vid = models.PositiveSmallIntegerField(
-        verbose_name='Maximum VLAN ID',
+        verbose_name=_('maximum VLAN ID'),
         default=VLAN_VID_MAX,
         default=VLAN_VID_MAX,
         validators=(
         validators=(
             MinValueValidator(VLAN_VID_MIN),
             MinValueValidator(VLAN_VID_MIN),
@@ -88,14 +90,14 @@ class VLANGroup(OrganizationalModel):
 
 
         # Validate scope assignment
         # Validate scope assignment
         if self.scope_type and not self.scope_id:
         if self.scope_type and not self.scope_id:
-            raise ValidationError("Cannot set scope_type without scope_id.")
+            raise ValidationError(_("Cannot set scope_type without scope_id."))
         if self.scope_id and not self.scope_type:
         if self.scope_id and not self.scope_type:
-            raise ValidationError("Cannot set scope_id without scope_type.")
+            raise ValidationError(_("Cannot set scope_id without scope_type."))
 
 
         # Validate min/max child VID limits
         # Validate min/max child VID limits
         if self.max_vid < self.min_vid:
         if self.max_vid < self.min_vid:
             raise ValidationError({
             raise ValidationError({
-                'max_vid': "Maximum child VID must be greater than or equal to minimum child VID"
+                'max_vid': _("Maximum child VID must be greater than or equal to minimum child VID")
             })
             })
 
 
     def get_available_vids(self):
     def get_available_vids(self):
@@ -143,7 +145,7 @@ class VLAN(PrimaryModel):
         help_text=_("VLAN group (optional)")
         help_text=_("VLAN group (optional)")
     )
     )
     vid = models.PositiveSmallIntegerField(
     vid = models.PositiveSmallIntegerField(
-        verbose_name='ID',
+        verbose_name=_('VLAN ID'),
         validators=(
         validators=(
             MinValueValidator(VLAN_VID_MIN),
             MinValueValidator(VLAN_VID_MIN),
             MaxValueValidator(VLAN_VID_MAX)
             MaxValueValidator(VLAN_VID_MAX)
@@ -151,6 +153,7 @@ class VLAN(PrimaryModel):
         help_text=_("Numeric VLAN ID (1-4094)")
         help_text=_("Numeric VLAN ID (1-4094)")
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=64
         max_length=64
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
@@ -161,6 +164,7 @@ class VLAN(PrimaryModel):
         null=True
         null=True
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=VLANStatusChoices,
         choices=VLANStatusChoices,
         default=VLANStatusChoices.STATUS_ACTIVE,
         default=VLANStatusChoices.STATUS_ACTIVE,
@@ -215,15 +219,17 @@ class VLAN(PrimaryModel):
         # Validate VLAN group (if assigned)
         # Validate VLAN group (if assigned)
         if self.group and self.site and self.group.scope != self.site:
         if self.group and self.site and self.group.scope != self.site:
             raise ValidationError({
             raise ValidationError({
-                'group': f"VLAN is assigned to group {self.group} (scope: {self.group.scope}); cannot also assign to "
-                         f"site {self.site}."
+                'group': _(
+                    "VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
+                ).format(group=self.group, scope=self.group.scope, site=self.site)
             })
             })
 
 
         # Validate group min/max VIDs
         # Validate group min/max VIDs
         if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:
         if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:
             raise ValidationError({
             raise ValidationError({
-                'vid': f"VID must be between {self.group.min_vid} and {self.group.max_vid} for VLANs in group "
-                       f"{self.group}"
+                'vid': _(
+                    "VID must be between {min_vid} and {max_vid} for VLANs in group {group}"
+                ).format(min_vid=self.group.min_vid, max_vid=self.group.max_vid, group=self.group)
             })
             })
 
 
     def get_status_color(self):
     def get_status_color(self):

+ 5 - 3
netbox/ipam/models/vrfs.py

@@ -1,6 +1,6 @@
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 
 from ipam.constants import *
 from ipam.constants import *
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
@@ -19,6 +19,7 @@ class VRF(PrimaryModel):
     are said to exist in the "global" table.)
     are said to exist in the "global" table.)
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     rd = models.CharField(
     rd = models.CharField(
@@ -26,7 +27,7 @@ class VRF(PrimaryModel):
         unique=True,
         unique=True,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Route distinguisher',
+        verbose_name=_('route distinguisher'),
         help_text=_('Unique route distinguisher (as defined in RFC 4364)')
         help_text=_('Unique route distinguisher (as defined in RFC 4364)')
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
@@ -38,7 +39,7 @@ class VRF(PrimaryModel):
     )
     )
     enforce_unique = models.BooleanField(
     enforce_unique = models.BooleanField(
         default=True,
         default=True,
-        verbose_name='Enforce unique space',
+        verbose_name=_('enforce unique space'),
         help_text=_('Prevent duplicate prefixes/IP addresses within this VRF')
         help_text=_('Prevent duplicate prefixes/IP addresses within this VRF')
     )
     )
     import_targets = models.ManyToManyField(
     import_targets = models.ManyToManyField(
@@ -75,6 +76,7 @@ class RouteTarget(PrimaryModel):
     A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364.
     A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=VRF_RD_MAX_LENGTH,  # Same format options as VRF RD (RFC 4360 section 4)
         max_length=VRF_RD_MAX_LENGTH,  # Same format options as VRF RD (RFC 4360 section 4)
         unique=True,
         unique=True,
         help_text=_('Route target value (formatted in accordance with RFC 4360)')
         help_text=_('Route target value (formatted in accordance with RFC 4360)')

+ 10 - 1
netbox/netbox/models/__init__.py

@@ -2,6 +2,7 @@ from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
+from django.utils.translation import gettext_lazy as _
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 
 
 from netbox.models.features import *
 from netbox.models.features import *
@@ -94,10 +95,12 @@ class PrimaryModel(NetBoxModel):
     Primary models represent real objects within the infrastructure being modeled.
     Primary models represent real objects within the infrastructure being modeled.
     """
     """
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
     comments = models.TextField(
     comments = models.TextField(
+        verbose_name=_('comments'),
         blank=True
         blank=True
     )
     )
 
 
@@ -119,12 +122,15 @@ class NestedGroupModel(CloningMixin, NetBoxFeatureSet, MPTTModel):
         db_index=True
         db_index=True
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100
         max_length=100
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
@@ -146,7 +152,7 @@ class NestedGroupModel(CloningMixin, NetBoxFeatureSet, MPTTModel):
         # An MPTT model cannot be its own parent
         # An MPTT model cannot be its own parent
         if self.pk and self.parent and self.parent in self.get_descendants(include_self=True):
         if self.pk and self.parent and self.parent in self.get_descendants(include_self=True):
             raise ValidationError({
             raise ValidationError({
-                "parent": f"Cannot assign self or child {self._meta.verbose_name} as parent."
+                "parent": "Cannot assign self or child {type} as parent.".format(type=self._meta.verbose_name)
             })
             })
 
 
 
 
@@ -160,14 +166,17 @@ class OrganizationalModel(NetBoxFeatureSet, models.Model):
     - Optional description
     - Optional description
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )

+ 6 - 1
netbox/netbox/models/features.py

@@ -9,7 +9,7 @@ from django.db import models
 from django.db.models.signals import class_prepared
 from django.db.models.signals import class_prepared
 from django.dispatch import receiver
 from django.dispatch import receiver
 from django.utils import timezone
 from django.utils import timezone
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
@@ -46,11 +46,13 @@ class ChangeLoggingMixin(models.Model):
     Provides change logging support for a model. Adds the `created` and `last_updated` fields.
     Provides change logging support for a model. Adds the `created` and `last_updated` fields.
     """
     """
     created = models.DateTimeField(
     created = models.DateTimeField(
+        verbose_name=_('created'),
         auto_now_add=True,
         auto_now_add=True,
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
     last_updated = models.DateTimeField(
     last_updated = models.DateTimeField(
+        verbose_name=_('last updated'),
         auto_now=True,
         auto_now=True,
         blank=True,
         blank=True,
         null=True
         null=True
@@ -401,16 +403,19 @@ class SyncedDataMixin(models.Model):
         related_name='+'
         related_name='+'
     )
     )
     data_path = models.CharField(
     data_path = models.CharField(
+        verbose_name=_('data path'),
         max_length=1000,
         max_length=1000,
         blank=True,
         blank=True,
         editable=False,
         editable=False,
         help_text=_("Path to remote file (relative to data source root)")
         help_text=_("Path to remote file (relative to data source root)")
     )
     )
     auto_sync_enabled = models.BooleanField(
     auto_sync_enabled = models.BooleanField(
+        verbose_name=_('auto sync enabled'),
         default=False,
         default=False,
         help_text=_("Enable automatic synchronization of data when the data file is updated")
         help_text=_("Enable automatic synchronization of data when the data file is updated")
     )
     )
     data_synced = models.DateTimeField(
     data_synced = models.DateTimeField(
+        verbose_name=_('date synced'),
         blank=True,
         blank=True,
         null=True,
         null=True,
         editable=False
         editable=False

+ 8 - 0
netbox/tenancy/models/contacts.py

@@ -2,6 +2,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
 
 
 from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
 from tenancy.choices import *
 from tenancy.choices import *
@@ -51,24 +52,30 @@ class Contact(PrimaryModel):
         null=True
         null=True
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     title = models.CharField(
     title = models.CharField(
+        verbose_name=_('title'),
         max_length=100,
         max_length=100,
         blank=True
         blank=True
     )
     )
     phone = models.CharField(
     phone = models.CharField(
+        verbose_name=_('phone'),
         max_length=50,
         max_length=50,
         blank=True
         blank=True
     )
     )
     email = models.EmailField(
     email = models.EmailField(
+        verbose_name=_('email'),
         blank=True
         blank=True
     )
     )
     address = models.CharField(
     address = models.CharField(
+        verbose_name=_('address'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
     link = models.URLField(
     link = models.URLField(
+        verbose_name=_('link'),
         blank=True
         blank=True
     )
     )
 
 
@@ -113,6 +120,7 @@ class ContactAssignment(ChangeLoggedModel):
         related_name='assignments'
         related_name='assignments'
     )
     )
     priority = models.CharField(
     priority = models.CharField(
+        verbose_name=_('priority'),
         max_length=50,
         max_length=50,
         choices=ContactPriorityChoices,
         choices=ContactPriorityChoices,
         blank=True
         blank=True

+ 7 - 2
netbox/tenancy/models/tenants.py

@@ -2,6 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.db.models import Q
 from django.db.models import Q
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
 
 
 from netbox.models import NestedGroupModel, PrimaryModel
 from netbox.models import NestedGroupModel, PrimaryModel
 
 
@@ -16,10 +17,12 @@ class TenantGroup(NestedGroupModel):
     An arbitrary collection of Tenants.
     An arbitrary collection of Tenants.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
@@ -37,9 +40,11 @@ class Tenant(PrimaryModel):
     department.
     department.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100
         max_length=100
     )
     )
     group = models.ForeignKey(
     group = models.ForeignKey(
@@ -65,7 +70,7 @@ class Tenant(PrimaryModel):
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('group', 'name'),
                 fields=('group', 'name'),
                 name='%(app_label)s_%(class)s_unique_group_name',
                 name='%(app_label)s_%(class)s_unique_group_name',
-                violation_error_message="Tenant name must be unique per group."
+                violation_error_message=_("Tenant name must be unique per group.")
             ),
             ),
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('name',),
                 fields=('name',),
@@ -75,7 +80,7 @@ class Tenant(PrimaryModel):
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('group', 'slug'),
                 fields=('group', 'slug'),
                 name='%(app_label)s_%(class)s_unique_group_slug',
                 name='%(app_label)s_%(class)s_unique_group_slug',
-                violation_error_message="Tenant slug must be unique per group."
+                violation_error_message=_("Tenant slug must be unique per group.")
             ),
             ),
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('slug',),
                 fields=('slug',),

+ 22 - 8
netbox/users/models.py

@@ -11,7 +11,7 @@ from django.db.models.signals import post_save
 from django.dispatch import receiver
 from django.dispatch import receiver
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 
 
 from ipam.fields import IPNetworkField
 from ipam.fields import IPNetworkField
@@ -39,7 +39,7 @@ class AdminGroup(Group):
     Proxy contrib.auth.models.Group for the admin UI
     Proxy contrib.auth.models.Group for the admin UI
     """
     """
     class Meta:
     class Meta:
-        verbose_name = 'Group'
+        verbose_name = _('Group')
         proxy = True
         proxy = True
 
 
 
 
@@ -48,7 +48,7 @@ class AdminUser(User):
     Proxy contrib.auth.models.User for the admin UI
     Proxy contrib.auth.models.User for the admin UI
     """
     """
     class Meta:
     class Meta:
-        verbose_name = 'User'
+        verbose_name = _('User')
         proxy = True
         proxy = True
 
 
 
 
@@ -109,7 +109,7 @@ class UserConfig(models.Model):
 
 
     class Meta:
     class Meta:
         ordering = ['user']
         ordering = ['user']
-        verbose_name = verbose_name_plural = 'User Preferences'
+        verbose_name = verbose_name_plural = _('User Preferences')
 
 
     def get(self, path, default=None):
     def get(self, path, default=None):
         """
         """
@@ -175,7 +175,9 @@ class UserConfig(models.Model):
                 d = d[key]
                 d = d[key]
             elif key in d:
             elif key in d:
                 err_path = '.'.join(path.split('.')[:i + 1])
                 err_path = '.'.join(path.split('.')[:i + 1])
-                raise TypeError(f"Key '{err_path}' is a leaf node; cannot assign new keys")
+                raise TypeError(
+                    _("Key '{err_path}' is a leaf node; cannot assign new keys").format(err_path=err_path)
+                )
             else:
             else:
                 d = d.setdefault(key, {})
                 d = d.setdefault(key, {})
 
 
@@ -185,7 +187,9 @@ class UserConfig(models.Model):
             if type(value) is dict:
             if type(value) is dict:
                 d[key].update(value)
                 d[key].update(value)
             else:
             else:
-                raise TypeError(f"Key '{path}' is a dictionary; cannot assign a non-dictionary value")
+                raise TypeError(
+                    _("Key '{path}' is a dictionary; cannot assign a non-dictionary value").format(path=path)
+                )
         else:
         else:
             d[key] = value
             d[key] = value
 
 
@@ -245,26 +249,32 @@ class Token(models.Model):
         related_name='tokens'
         related_name='tokens'
     )
     )
     created = models.DateTimeField(
     created = models.DateTimeField(
+        verbose_name=_('created'),
         auto_now_add=True
         auto_now_add=True
     )
     )
     expires = models.DateTimeField(
     expires = models.DateTimeField(
+        verbose_name=_('expires'),
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
     last_used = models.DateTimeField(
     last_used = models.DateTimeField(
+        verbose_name=_('last used'),
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
     key = models.CharField(
     key = models.CharField(
+        verbose_name=_('key'),
         max_length=40,
         max_length=40,
         unique=True,
         unique=True,
         validators=[MinLengthValidator(40)]
         validators=[MinLengthValidator(40)]
     )
     )
     write_enabled = models.BooleanField(
     write_enabled = models.BooleanField(
+        verbose_name=_('write enabled'),
         default=True,
         default=True,
         help_text=_('Permit create/update/delete operations using this key')
         help_text=_('Permit create/update/delete operations using this key')
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
@@ -272,7 +282,7 @@ class Token(models.Model):
         base_field=IPNetworkField(),
         base_field=IPNetworkField(),
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Allowed IPs',
+        verbose_name=_('allowed IPs'),
         help_text=_(
         help_text=_(
             'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
             'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
             'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"'
             'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"'
@@ -331,13 +341,16 @@ class ObjectPermission(models.Model):
     identified by ORM query parameters.
     identified by ORM query parameters.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
     enabled = models.BooleanField(
     enabled = models.BooleanField(
+        verbose_name=_('enabled'),
         default=True
         default=True
     )
     )
     object_types = models.ManyToManyField(
     object_types = models.ManyToManyField(
@@ -362,6 +375,7 @@ class ObjectPermission(models.Model):
     constraints = models.JSONField(
     constraints = models.JSONField(
         blank=True,
         blank=True,
         null=True,
         null=True,
+        verbose_name=_('constraints'),
         help_text=_("Queryset filter matching the applicable objects of the selected type(s)")
         help_text=_("Queryset filter matching the applicable objects of the selected type(s)")
     )
     )
 
 
@@ -369,7 +383,7 @@ class ObjectPermission(models.Model):
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
-        verbose_name = "permission"
+        verbose_name = _("permission")
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name

+ 5 - 1
netbox/virtualization/models/clusters.py

@@ -2,6 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import Device
 from dcim.models import Device
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
@@ -46,9 +47,11 @@ class Cluster(PrimaryModel):
     A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
     A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100
         max_length=100
     )
     )
     type = models.ForeignKey(
     type = models.ForeignKey(
+        verbose_name=_('type'),
         to=ClusterType,
         to=ClusterType,
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='clusters'
         related_name='clusters'
@@ -61,6 +64,7 @@ class Cluster(PrimaryModel):
         null=True
         null=True
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=ClusterStatusChoices,
         choices=ClusterStatusChoices,
         default=ClusterStatusChoices.STATUS_ACTIVE
         default=ClusterStatusChoices.STATUS_ACTIVE
@@ -128,7 +132,7 @@ class Cluster(PrimaryModel):
             nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count()
             nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count()
             if nonsite_devices:
             if nonsite_devices:
                 raise ValidationError({
                 raise ValidationError({
-                    'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format(
+                    'site': _("{} devices are assigned as hosts for this cluster but are not in site {}").format(
                         nonsite_devices, self.site
                         nonsite_devices, self.site
                     )
                     )
                 })
                 })

+ 40 - 24
netbox/virtualization/models/virtualmachines.py

@@ -5,6 +5,7 @@ from django.db import models
 from django.db.models import Q
 from django.db.models import Q
 from django.db.models.functions import Lower
 from django.db.models.functions import Lower
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import BaseInterface
 from dcim.models import BaseInterface
 from extras.models import ConfigContextModel
 from extras.models import ConfigContextModel
@@ -63,6 +64,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
         null=True
         null=True
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=64
         max_length=64
     )
     )
     _name = NaturalOrderingField(
     _name = NaturalOrderingField(
@@ -74,7 +76,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
         max_length=50,
         max_length=50,
         choices=VirtualMachineStatusChoices,
         choices=VirtualMachineStatusChoices,
         default=VirtualMachineStatusChoices.STATUS_ACTIVE,
         default=VirtualMachineStatusChoices.STATUS_ACTIVE,
-        verbose_name='Status'
+        verbose_name=_('status')
     )
     )
     role = models.ForeignKey(
     role = models.ForeignKey(
         to='dcim.DeviceRole',
         to='dcim.DeviceRole',
@@ -90,7 +92,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
         related_name='+',
         related_name='+',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Primary IPv4'
+        verbose_name=_('primary IPv4')
     )
     )
     primary_ip6 = models.OneToOneField(
     primary_ip6 = models.OneToOneField(
         to='ipam.IPAddress',
         to='ipam.IPAddress',
@@ -98,14 +100,14 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
         related_name='+',
         related_name='+',
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Primary IPv6'
+        verbose_name=_('primary IPv6')
     )
     )
     vcpus = models.DecimalField(
     vcpus = models.DecimalField(
         max_digits=6,
         max_digits=6,
         decimal_places=2,
         decimal_places=2,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='vCPUs',
+        verbose_name=_('vCPUs'),
         validators=(
         validators=(
             MinValueValidator(0.01),
             MinValueValidator(0.01),
         )
         )
@@ -113,12 +115,12 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
     memory = models.PositiveIntegerField(
     memory = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Memory (MB)'
+        verbose_name=_('memory (MB)')
     )
     )
     disk = models.PositiveIntegerField(
     disk = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Disk (GB)'
+        verbose_name=_('disk (GB)')
     )
     )
 
 
     # Counter fields
     # Counter fields
@@ -152,7 +154,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
                 Lower('name'), 'cluster',
                 Lower('name'), 'cluster',
                 name='%(app_label)s_%(class)s_unique_name_cluster',
                 name='%(app_label)s_%(class)s_unique_name_cluster',
                 condition=Q(tenant__isnull=True),
                 condition=Q(tenant__isnull=True),
-                violation_error_message="Virtual machine name must be unique per cluster."
+                violation_error_message=_("Virtual machine name must be unique per cluster.")
             ),
             ),
         )
         )
 
 
@@ -168,23 +170,27 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
         # Must be assigned to a site and/or cluster
         # Must be assigned to a site and/or cluster
         if not self.site and not self.cluster:
         if not self.site and not self.cluster:
             raise ValidationError({
             raise ValidationError({
-                'cluster': f'A virtual machine must be assigned to a site and/or cluster.'
+                'cluster': _('A virtual machine must be assigned to a site and/or cluster.')
             })
             })
 
 
         # Validate site for cluster & device
         # Validate site for cluster & device
         if self.cluster and self.site and self.cluster.site != self.site:
         if self.cluster and self.site and self.cluster.site != self.site:
             raise ValidationError({
             raise ValidationError({
-                'cluster': f'The selected cluster ({self.cluster}) is not assigned to this site ({self.site}).'
+                'cluster': _(
+                    'The selected cluster ({cluster}) is not assigned to this site ({site}).'
+                ).format(cluster=self.cluster, site=self.site)
             })
             })
 
 
         # Validate assigned cluster device
         # Validate assigned cluster device
         if self.device and not self.cluster:
         if self.device and not self.cluster:
             raise ValidationError({
             raise ValidationError({
-                'device': f'Must specify a cluster when assigning a host device.'
+                'device': _('Must specify a cluster when assigning a host device.')
             })
             })
         if self.device and self.device not in self.cluster.devices.all():
         if self.device and self.device not in self.cluster.devices.all():
             raise ValidationError({
             raise ValidationError({
-                'device': f'The selected device ({self.device}) is not assigned to this cluster ({self.cluster}).'
+                'device': _(
+                    "The selected device ({device}) is not assigned to this cluster ({cluster})."
+                ).format(device=self.device, cluster=self.cluster)
             })
             })
 
 
         # Validate primary IP addresses
         # Validate primary IP addresses
@@ -195,7 +201,9 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
             if ip is not None:
             if ip is not None:
                 if ip.address.version != family:
                 if ip.address.version != family:
                     raise ValidationError({
                     raise ValidationError({
-                        field: f"Must be an IPv{family} address. ({ip} is an IPv{ip.address.version} address.)",
+                        field: _(
+                            "Must be an IPv{family} address. ({ip} is an IPv{version} address.)"
+                        ).format(family=family, ip=ip, version=ip.address.version)
                     })
                     })
                 if ip.assigned_object in interfaces:
                 if ip.assigned_object in interfaces:
                     pass
                     pass
@@ -203,7 +211,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
                     pass
                     pass
                 else:
                 else:
                     raise ValidationError({
                     raise ValidationError({
-                        field: f"The specified IP address ({ip}) is not assigned to this VM.",
+                        field: _("The specified IP address ({ip}) is not assigned to this VM.").format(ip=ip),
                     })
                     })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -236,6 +244,7 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
         related_name='interfaces'
         related_name='interfaces'
     )
     )
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=64
         max_length=64
     )
     )
     _name = NaturalOrderingField(
     _name = NaturalOrderingField(
@@ -245,6 +254,7 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
         blank=True
         blank=True
     )
     )
     description = models.CharField(
     description = models.CharField(
+        verbose_name=_('description'),
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
@@ -254,13 +264,13 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
         related_name='vminterfaces_as_untagged',
         related_name='vminterfaces_as_untagged',
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='Untagged VLAN'
+        verbose_name=_('untagged VLAN')
     )
     )
     tagged_vlans = models.ManyToManyField(
     tagged_vlans = models.ManyToManyField(
         to='ipam.VLAN',
         to='ipam.VLAN',
         related_name='vminterfaces_as_tagged',
         related_name='vminterfaces_as_tagged',
         blank=True,
         blank=True,
-        verbose_name='Tagged VLANs'
+        verbose_name=_('tagged VLANs')
     )
     )
     ip_addresses = GenericRelation(
     ip_addresses = GenericRelation(
         to='ipam.IPAddress',
         to='ipam.IPAddress',
@@ -274,7 +284,7 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
         related_name='vminterfaces',
         related_name='vminterfaces',
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name='VRF'
+        verbose_name=_('VRF')
     )
     )
     fhrp_group_assignments = GenericRelation(
     fhrp_group_assignments = GenericRelation(
         to='ipam.FHRPGroupAssignment',
         to='ipam.FHRPGroupAssignment',
@@ -312,26 +322,30 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
 
 
         # An interface cannot be its own parent
         # An interface cannot be its own parent
         if self.pk and self.parent_id == self.pk:
         if self.pk and self.parent_id == self.pk:
-            raise ValidationError({'parent': "An interface cannot be its own parent."})
+            raise ValidationError({'parent': _("An interface cannot be its own parent.")})
 
 
         # An interface's parent must belong to the same virtual machine
         # An interface's parent must belong to the same virtual machine
         if self.parent and self.parent.virtual_machine != self.virtual_machine:
         if self.parent and self.parent.virtual_machine != self.virtual_machine:
             raise ValidationError({
             raise ValidationError({
-                'parent': f"The selected parent interface ({self.parent}) belongs to a different virtual machine "
-                          f"({self.parent.virtual_machine})."
+                'parent': _(
+                    "The selected parent interface ({parent}) belongs to a different virtual machine "
+                    "({virtual_machine})."
+                ).format(parent=self.parent, virtual_machine=self.parent.virtual_machine)
             })
             })
 
 
         # Bridge validation
         # Bridge validation
 
 
         # An interface cannot be bridged to itself
         # An interface cannot be bridged to itself
         if self.pk and self.bridge_id == self.pk:
         if self.pk and self.bridge_id == self.pk:
-            raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
+            raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
 
 
         # A bridged interface belong to the same virtual machine
         # A bridged interface belong to the same virtual machine
         if self.bridge and self.bridge.virtual_machine != self.virtual_machine:
         if self.bridge and self.bridge.virtual_machine != self.virtual_machine:
             raise ValidationError({
             raise ValidationError({
-                'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different virtual machine "
-                          f"({self.bridge.virtual_machine})."
+                'bridge': _(
+                    "The selected bridge interface ({bridge}) belongs to a different virtual machine "
+                    "({virtual_machine})."
+                ).format(bridge=self.bridge, virtual_machine=self.bridge.virtual_machine)
             })
             })
 
 
         # VLAN validation
         # VLAN validation
@@ -339,8 +353,10 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
         # Validate untagged VLAN
         # Validate untagged VLAN
         if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
         if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
             raise ValidationError({
             raise ValidationError({
-                'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
-                                 f"interface's parent virtual machine, or it must be global."
+                'untagged_vlan': _(
+                    "The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
+                    "virtual machine, or it must be global."
+                ).format(untagged_vlan=self.untagged_vlan)
             })
             })
 
 
     def to_objectchange(self, action):
     def to_objectchange(self, action):

+ 20 - 10
netbox/wireless/models.py

@@ -1,6 +1,7 @@
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
 from mptt.models import MPTTModel
 from mptt.models import MPTTModel
 
 
 from dcim.choices import LinkStatusChoices
 from dcim.choices import LinkStatusChoices
@@ -24,9 +25,10 @@ class WirelessAuthenticationBase(models.Model):
         max_length=50,
         max_length=50,
         choices=WirelessAuthTypeChoices,
         choices=WirelessAuthTypeChoices,
         blank=True,
         blank=True,
-        verbose_name="Auth Type",
+        verbose_name=_("authentication type"),
     )
     )
     auth_cipher = models.CharField(
     auth_cipher = models.CharField(
+        verbose_name=_('authentication cipher'),
         max_length=50,
         max_length=50,
         choices=WirelessAuthCipherChoices,
         choices=WirelessAuthCipherChoices,
         blank=True
         blank=True
@@ -34,7 +36,7 @@ class WirelessAuthenticationBase(models.Model):
     auth_psk = models.CharField(
     auth_psk = models.CharField(
         max_length=PSK_MAX_LENGTH,
         max_length=PSK_MAX_LENGTH,
         blank=True,
         blank=True,
-        verbose_name='Pre-shared key'
+        verbose_name=_('pre-shared key')
     )
     )
 
 
     class Meta:
     class Meta:
@@ -46,10 +48,12 @@ class WirelessLANGroup(NestedGroupModel):
     A nested grouping of WirelessLANs
     A nested grouping of WirelessLANs
     """
     """
     name = models.CharField(
     name = models.CharField(
+        verbose_name=_('name'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
+        verbose_name=_('slug'),
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
@@ -74,7 +78,7 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel):
     """
     """
     ssid = models.CharField(
     ssid = models.CharField(
         max_length=SSID_MAX_LENGTH,
         max_length=SSID_MAX_LENGTH,
-        verbose_name='SSID'
+        verbose_name=_('SSID')
     )
     )
     group = models.ForeignKey(
     group = models.ForeignKey(
         to='wireless.WirelessLANGroup',
         to='wireless.WirelessLANGroup',
@@ -86,14 +90,15 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel):
     status = models.CharField(
     status = models.CharField(
         max_length=50,
         max_length=50,
         choices=WirelessLANStatusChoices,
         choices=WirelessLANStatusChoices,
-        default=WirelessLANStatusChoices.STATUS_ACTIVE
+        default=WirelessLANStatusChoices.STATUS_ACTIVE,
+        verbose_name=_('status')
     )
     )
     vlan = models.ForeignKey(
     vlan = models.ForeignKey(
         to='ipam.VLAN',
         to='ipam.VLAN',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='VLAN'
+        verbose_name=_('VLAN')
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
@@ -134,21 +139,22 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
         limit_choices_to=get_wireless_interface_types,
         limit_choices_to=get_wireless_interface_types,
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='+',
         related_name='+',
-        verbose_name="Interface A",
+        verbose_name=_('interface A'),
     )
     )
     interface_b = models.ForeignKey(
     interface_b = models.ForeignKey(
         to='dcim.Interface',
         to='dcim.Interface',
         limit_choices_to=get_wireless_interface_types,
         limit_choices_to=get_wireless_interface_types,
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='+',
         related_name='+',
-        verbose_name="Interface B",
+        verbose_name=_('interface B'),
     )
     )
     ssid = models.CharField(
     ssid = models.CharField(
         max_length=SSID_MAX_LENGTH,
         max_length=SSID_MAX_LENGTH,
         blank=True,
         blank=True,
-        verbose_name='SSID'
+        verbose_name=_('SSID')
     )
     )
     status = models.CharField(
     status = models.CharField(
+        verbose_name=_('status'),
         max_length=50,
         max_length=50,
         choices=LinkStatusChoices,
         choices=LinkStatusChoices,
         default=LinkStatusChoices.STATUS_CONNECTED
         default=LinkStatusChoices.STATUS_CONNECTED
@@ -203,11 +209,15 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
         # Validate interface types
         # Validate interface types
         if self.interface_a.type not in WIRELESS_IFACE_TYPES:
         if self.interface_a.type not in WIRELESS_IFACE_TYPES:
             raise ValidationError({
             raise ValidationError({
-                'interface_a': f"{self.interface_a.get_type_display()} is not a wireless interface."
+                'interface_a': _(
+                    "{type_display} is not a wireless interface."
+                ).format(type_display=self.interface_a.get_type_display())
             })
             })
         if self.interface_b.type not in WIRELESS_IFACE_TYPES:
         if self.interface_b.type not in WIRELESS_IFACE_TYPES:
             raise ValidationError({
             raise ValidationError({
-                'interface_a': f"{self.interface_b.get_type_display()} is not a wireless interface."
+                'interface_a': _(
+                    "{type_display} is not a wireless interface."
+                ).format(type_display=self.interface_b.get_type_display())
             })
             })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):