diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index f629c0b3008..af8b5f042cb 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from django.db import models 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 dcim.models import CabledObjectModel @@ -34,8 +34,8 @@ class Circuit(PrimaryModel): """ cid = models.CharField( max_length=100, - verbose_name='Circuit ID', - help_text=_("Unique circuit ID") + verbose_name=_('circuit ID'), + help_text=_('Unique circuit ID') ) provider = models.ForeignKey( to='circuits.Provider', @@ -55,6 +55,7 @@ class Circuit(PrimaryModel): related_name='circuits' ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=CircuitStatusChoices, default=CircuitStatusChoices.STATUS_ACTIVE @@ -69,17 +70,17 @@ class Circuit(PrimaryModel): install_date = models.DateField( blank=True, null=True, - verbose_name='Installed' + verbose_name=_('installed') ) termination_date = models.DateField( blank=True, null=True, - verbose_name='Terminates' + verbose_name=_('terminates') ) commit_rate = models.PositiveIntegerField( blank=True, null=True, - verbose_name='Commit rate (Kbps)', + verbose_name=_('commit rate (Kbps)'), help_text=_("Committed rate") ) @@ -162,7 +163,7 @@ class CircuitTermination( term_side = models.CharField( max_length=1, choices=CircuitTerminationSideChoices, - verbose_name='Termination' + verbose_name=_('termination') ) site = models.ForeignKey( to='dcim.Site', @@ -179,30 +180,31 @@ class CircuitTermination( null=True ) port_speed = models.PositiveIntegerField( - verbose_name='Port speed (Kbps)', + verbose_name=_('port speed (Kbps)'), blank=True, null=True, - help_text=_("Physical circuit speed") + help_text=_('Physical circuit speed') ) upstream_speed = models.PositiveIntegerField( blank=True, null=True, - verbose_name='Upstream speed (Kbps)', + verbose_name=_('upstream speed (Kbps)'), help_text=_('Upstream speed, if different from port speed') ) xconnect_id = models.CharField( max_length=50, 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( max_length=100, 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( + verbose_name=_('description'), max_length=200, blank=True ) diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 52eb26c98ab..e28a82a79c4 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -2,7 +2,7 @@ from django.db import models from django.db.models import Q 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 @@ -19,11 +19,13 @@ class Provider(PrimaryModel): stores information pertinent to the user's relationship with the Provider. """ name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True, - help_text=_("Full name of the provider") + help_text=_('Full name of the provider') ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100, unique=True ) @@ -61,9 +63,10 @@ class ProviderAccount(PrimaryModel): ) account = models.CharField( max_length=100, - verbose_name='Account ID' + verbose_name=_('account ID') ) name = models.CharField( + verbose_name=_('name'), max_length=100, blank=True ) @@ -104,6 +107,7 @@ class ProviderNetwork(PrimaryModel): unimportant to the user. """ name = models.CharField( + verbose_name=_('name'), max_length=100 ) provider = models.ForeignKey( @@ -114,7 +118,7 @@ class ProviderNetwork(PrimaryModel): service_id = models.CharField( max_length=100, blank=True, - verbose_name='Service ID' + verbose_name=_('service ID') ) class Meta: diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index a2a20f858ed..d1a03379867 100644 --- a/netbox/core/models/data.py +++ b/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. """ name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) type = models.CharField( + verbose_name=_('type'), max_length=50, choices=DataSourceTypeChoices, default=DataSourceTypeChoices.LOCAL @@ -52,23 +54,28 @@ class DataSource(JobsMixin, PrimaryModel): verbose_name=_('URL') ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=DataSourceStatusChoices, default=DataSourceStatusChoices.NEW, editable=False ) enabled = models.BooleanField( + verbose_name=_('enabled'), default=True ) ignore_rules = models.TextField( + verbose_name=_('ignore rules'), blank=True, help_text=_("Patterns (one per line) matching files to ignore when syncing") ) parameters = models.JSONField( + verbose_name=_('parameters'), blank=True, null=True ) last_synced = models.DateTimeField( + verbose_name=_('last synced'), blank=True, null=True, editable=False @@ -239,9 +246,11 @@ class DataFile(models.Model): updated, or deleted only by calling DataSource.sync(). """ created = models.DateTimeField( + verbose_name=_('created'), auto_now_add=True ) last_updated = models.DateTimeField( + verbose_name=_('last updated'), editable=False ) source = models.ForeignKey( @@ -251,20 +260,23 @@ class DataFile(models.Model): editable=False ) path = models.CharField( + verbose_name=_('path'), max_length=1000, editable=False, help_text=_("File path relative to the data source's root") ) size = models.PositiveIntegerField( - editable=False + editable=False, + verbose_name=_('size') ) hash = models.CharField( + verbose_name=_('hash'), max_length=64, editable=False, validators=[ 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() diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index a725ea0ac28..c70f4c4fa78 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -23,20 +23,24 @@ class ManagedFile(SyncedDataMixin, models.Model): to provide additional functionality. """ created = models.DateTimeField( + verbose_name=_('created'), auto_now_add=True ) last_updated = models.DateTimeField( + verbose_name=_('last updated'), editable=False, blank=True, null=True ) file_root = models.CharField( + verbose_name=_('file root'), max_length=1000, choices=ManagedFileRootPathChoices ) file_path = models.FilePathField( + verbose_name=_('file path'), 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() diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 9be06bd6de7..63a918eb5a8 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -43,28 +43,34 @@ class Job(models.Model): for_concrete_model=False ) name = models.CharField( + verbose_name=_('name'), max_length=200 ) created = models.DateTimeField( + verbose_name=_('created'), auto_now_add=True ) scheduled = models.DateTimeField( + verbose_name=_('scheduled'), null=True, blank=True ) interval = models.PositiveIntegerField( + verbose_name=_('interval'), blank=True, null=True, validators=( MinValueValidator(1), ), - help_text=_("Recurrence interval (in minutes)") + help_text=_('Recurrence interval (in minutes)') ) started = models.DateTimeField( + verbose_name=_('started'), null=True, blank=True ) completed = models.DateTimeField( + verbose_name=_('completed'), null=True, blank=True ) @@ -76,15 +82,18 @@ class Job(models.Model): null=True ) status = models.CharField( + verbose_name=_('status'), max_length=30, choices=JobStatusChoices, default=JobStatusChoices.STATUS_PENDING ) data = models.JSONField( + verbose_name=_('data'), null=True, blank=True ) job_id = models.UUIDField( + verbose_name=_('job ID'), unique=True ) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index b2786719c74..5212dbff387 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -8,6 +8,7 @@ from django.db.models import Sum from django.dispatch import Signal from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from dcim.choices import * from dcim.constants import * @@ -40,11 +41,13 @@ class Cable(PrimaryModel): A physical connection between two endpoints. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=CableTypeChoices, blank=True ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=LinkStatusChoices, default=LinkStatusChoices.STATUS_CONNECTED @@ -57,19 +60,23 @@ class Cable(PrimaryModel): null=True ) label = models.CharField( + verbose_name=_('label'), max_length=100, blank=True ) color = ColorField( + verbose_name=_('color'), blank=True ) length = models.DecimalField( + verbose_name=_('length'), max_digits=8, decimal_places=2, blank=True, null=True ) length_unit = models.CharField( + verbose_name=_('length unit'), max_length=50, choices=CableLengthUnitChoices, blank=True, @@ -235,7 +242,7 @@ class CableTermination(ChangeLoggedModel): cable_end = models.CharField( max_length=1, choices=CableEndChoices, - verbose_name='End' + verbose_name=_('end') ) termination_type = models.ForeignKey( 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. """ path = models.JSONField( + verbose_name=_('path'), default=list ) is_active = models.BooleanField( + verbose_name=_('is active'), default=False ) is_complete = models.BooleanField( + verbose_name=_('is complete'), default=False ) is_split = models.BooleanField( + verbose_name=_('is split'), default=False ) _nodes = PathField() diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index d4539a6aba7..94ff2a8ced7 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator 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 dcim.choices import * @@ -41,10 +41,11 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): related_name='%(class)ss' ) name = models.CharField( + verbose_name=_('name'), 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( target_field='name', @@ -52,11 +53,13 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): blank=True ) label = models.CharField( + verbose_name=_('label'), max_length=64, blank=True, - help_text=_("Physical label") + help_text=_('Physical label') ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) @@ -98,7 +101,7 @@ def clean(self): if self.pk is not None and self._original_device_type != self.device_type_id: 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 @@ def clean(self): # A component template must belong to a DeviceType *or* to a ModuleType if self.device_type and self.module_type: 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: 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): @@ -172,6 +175,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel): A template for a ConsolePort to be created for a new Device. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=ConsolePortTypeChoices, blank=True @@ -201,6 +205,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): A template for a ConsoleServerPort to be created for a new Device. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=ConsolePortTypeChoices, blank=True @@ -231,21 +236,24 @@ class PowerPortTemplate(ModularComponentTemplateModel): A template for a PowerPort to be created for a new Device. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=PowerPortTypeChoices, blank=True ) maximum_draw = models.PositiveIntegerField( + verbose_name=_('maximum draw'), blank=True, null=True, validators=[MinValueValidator(1)], - help_text=_("Maximum power draw (watts)") + help_text=_('Maximum power draw (watts)') ) allocated_draw = models.PositiveIntegerField( + verbose_name=_('allocated draw'), blank=True, null=True, validators=[MinValueValidator(1)], - help_text=_("Allocated power draw (watts)") + help_text=_('Allocated power draw (watts)') ) component_model = PowerPort @@ -267,7 +275,7 @@ def clean(self): if self.maximum_draw is not None and self.allocated_draw is not None: if self.allocated_draw > self.maximum_draw: 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): @@ -286,6 +294,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel): A template for a PowerOutlet to be created for a new Device. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=PowerOutletTypeChoices, blank=True @@ -298,10 +307,11 @@ class PowerOutletTemplate(ModularComponentTemplateModel): related_name='poweroutlet_templates' ) feed_leg = models.CharField( + verbose_name=_('feed leg'), max_length=50, choices=PowerOutletFeedLegChoices, blank=True, - help_text=_("Phase (for three-phase feeds)") + help_text=_('Phase (for three-phase feeds)') ) component_model = PowerOutlet @@ -313,11 +323,11 @@ def clean(self): if self.power_port: if self.device_type and self.power_port.device_type != self.device_type: 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: 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): @@ -359,15 +369,17 @@ class InterfaceTemplate(ModularComponentTemplateModel): blank=True ) type = models.CharField( + verbose_name=_('type'), max_length=50, choices=InterfaceTypeChoices ) enabled = models.BooleanField( + verbose_name=_('enabled'), default=True ) mgmt_only = models.BooleanField( default=False, - verbose_name='Management only' + verbose_name=_('management only') ) bridge = models.ForeignKey( to='self', @@ -375,25 +387,25 @@ class InterfaceTemplate(ModularComponentTemplateModel): related_name='bridge_interfaces', null=True, blank=True, - verbose_name='Bridge interface' + verbose_name=_('bridge interface') ) poe_mode = models.CharField( max_length=50, choices=InterfacePoEModeChoices, blank=True, - verbose_name='PoE mode' + verbose_name=_('PoE mode') ) poe_type = models.CharField( max_length=50, choices=InterfacePoETypeChoices, blank=True, - verbose_name='PoE type' + verbose_name=_('PoE type') ) rf_role = models.CharField( max_length=30, choices=WirelessRoleChoices, blank=True, - verbose_name='Wireless role' + verbose_name=_('wireless role') ) component_model = Interface @@ -403,14 +415,14 @@ def clean(self): if self.bridge: 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: 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: 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: @@ -452,10 +464,12 @@ class FrontPortTemplate(ModularComponentTemplateModel): Template for a pass-through port on the front of a new Device. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=PortTypeChoices ) color = ColorField( + verbose_name=_('color'), blank=True ) rear_port = models.ForeignKey( @@ -464,6 +478,7 @@ class FrontPortTemplate(ModularComponentTemplateModel): related_name='frontport_templates' ) rear_port_position = models.PositiveSmallIntegerField( + verbose_name=_('rear port position'), default=1, validators=[ MinValueValidator(REARPORT_POSITIONS_MIN), @@ -497,13 +512,13 @@ def clean(self): # Validate rear port assignment if self.rear_port.device_type != self.device_type: 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 if self.rear_port_position > self.rear_port.positions: 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 ) ) @@ -545,13 +560,16 @@ class RearPortTemplate(ModularComponentTemplateModel): Template for a pass-through port on the rear of a new Device. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=PortTypeChoices ) color = ColorField( + verbose_name=_('color'), blank=True ) positions = models.PositiveSmallIntegerField( + verbose_name=_('positions'), default=1, validators=[ MinValueValidator(REARPORT_POSITIONS_MIN), @@ -588,6 +606,7 @@ class ModuleBayTemplate(ComponentTemplateModel): A template for a ModuleBay to be created for a new parent Device. """ position = models.CharField( + verbose_name=_('position'), max_length=30, blank=True, help_text=_('Identifier to reference when renaming installed components') @@ -630,7 +649,7 @@ def instantiate(self, device): def clean(self): if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT: 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): @@ -685,7 +704,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): ) part_id = models.CharField( max_length=50, - verbose_name='Part ID', + verbose_name=_('part ID'), blank=True, help_text=_('Manufacturer-assigned part identifier') ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 62f26776ff5..03ee33a6305 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -7,7 +7,7 @@ from django.db import models from django.db.models import Sum 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 dcim.choices import * @@ -52,6 +52,7 @@ class ComponentModel(NetBoxModel): related_name='%(class)ss' ) name = models.CharField( + verbose_name=_('name'), max_length=64 ) _name = NaturalOrderingField( @@ -60,11 +61,13 @@ class ComponentModel(NetBoxModel): blank=True ) label = models.CharField( + verbose_name=_('label'), max_length=64, blank=True, - help_text=_("Physical label") + help_text=_('Physical label') ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) @@ -101,7 +104,7 @@ def clean(self): # 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): raise ValidationError({ - "device": "Components cannot be moved to a different device." + "device": _("Components cannot be moved to a different device.") }) @property @@ -140,13 +143,15 @@ class CabledObjectModel(models.Model): null=True ) cable_end = models.CharField( + verbose_name=_('cable end'), max_length=1, blank=True, choices=CableEndChoices ) mark_connected = models.BooleanField( + verbose_name=_('mark connected'), default=False, - help_text=_("Treat as if a cable is connected") + help_text=_('Treat as if a cable is connected') ) cable_terminations = GenericRelation( @@ -164,15 +169,15 @@ def clean(self): if self.cable and not self.cable_end: 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: 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: raise ValidationError({ - "mark_connected": "Cannot mark as connected with a cable attached." + "mark_connected": _("Cannot mark as connected with a cable attached.") }) @property @@ -195,7 +200,9 @@ def _occupied(self): @property 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 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. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=ConsolePortTypeChoices, blank=True, help_text=_('Physical port type') ) speed = models.PositiveIntegerField( + verbose_name=_('speed'), choices=ConsolePortSpeedChoices, blank=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. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=ConsolePortTypeChoices, blank=True, help_text=_('Physical port type') ) speed = models.PositiveIntegerField( + verbose_name=_('speed'), choices=ConsolePortSpeedChoices, blank=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. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=PowerPortTypeChoices, blank=True, help_text=_('Physical port type') ) maximum_draw = models.PositiveIntegerField( + verbose_name=_('maximum draw'), blank=True, null=True, validators=[MinValueValidator(1)], help_text=_("Maximum power draw (watts)") ) allocated_draw = models.PositiveIntegerField( + verbose_name=_('allocated draw'), blank=True, null=True, validators=[MinValueValidator(1)], - help_text=_("Allocated power draw (watts)") + help_text=_('Allocated power draw (watts)') ) clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw') @@ -354,7 +368,9 @@ def clean(self): if self.maximum_draw is not None and self.allocated_draw is not None: if self.allocated_draw > self.maximum_draw: 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): @@ -434,6 +450,7 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki A physical power outlet (output) within a Device which provides power to a PowerPort. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=PowerOutletTypeChoices, blank=True, @@ -447,10 +464,11 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki related_name='poweroutlets' ) feed_leg = models.CharField( + verbose_name=_('feed leg'), max_length=50, choices=PowerOutletFeedLegChoices, 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') @@ -463,7 +481,9 @@ def clean(self): # Validate power port assignment 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. """ enabled = models.BooleanField( + verbose_name=_('enabled'), default=True ) mac_address = MACAddressField( null=True, blank=True, - verbose_name='MAC Address' + verbose_name=_('MAC address') ) mtu = models.PositiveIntegerField( blank=True, @@ -489,13 +510,14 @@ class BaseInterface(models.Model): MinValueValidator(INTERFACE_MTU_MIN), MaxValueValidator(INTERFACE_MTU_MAX) ], - verbose_name='MTU' + verbose_name=_('MTU') ) mode = models.CharField( + verbose_name=_('mode'), max_length=50, choices=InterfaceModeChoices, blank=True, - help_text=_("IEEE 802.1Q tagging strategy") + help_text=_('IEEE 802.1Q tagging strategy') ) parent = models.ForeignKey( to='self', @@ -503,7 +525,7 @@ class BaseInterface(models.Model): related_name='child_interfaces', null=True, blank=True, - verbose_name='Parent interface' + verbose_name=_('parent interface') ) bridge = models.ForeignKey( to='self', @@ -511,7 +533,7 @@ class BaseInterface(models.Model): related_name='bridge_interfaces', null=True, blank=True, - verbose_name='Bridge interface' + verbose_name=_('bridge interface') ) class Meta: @@ -559,23 +581,25 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd related_name='member_interfaces', null=True, blank=True, - verbose_name='Parent LAG' + verbose_name=_('parent LAG') ) type = models.CharField( + verbose_name=_('type'), max_length=50, choices=InterfaceTypeChoices ) mgmt_only = models.BooleanField( default=False, - verbose_name='Management only', + verbose_name=_('management only'), help_text=_('This interface is used only for out-of-band management') ) speed = models.PositiveIntegerField( blank=True, null=True, - verbose_name='Speed (Kbps)' + verbose_name=_('speed (Kbps)') ) duplex = models.CharField( + verbose_name=_('duplex'), max_length=50, blank=True, null=True, @@ -584,27 +608,27 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd wwn = WWNField( null=True, blank=True, - verbose_name='WWN', + verbose_name=_('WWN'), help_text=_('64-bit World Wide Name') ) rf_role = models.CharField( max_length=30, choices=WirelessRoleChoices, blank=True, - verbose_name='Wireless role' + verbose_name=_('wireless role') ) rf_channel = models.CharField( max_length=50, choices=WirelessChannelChoices, blank=True, - verbose_name='Wireless channel' + verbose_name=_('wireless channel') ) rf_channel_frequency = models.DecimalField( max_digits=7, decimal_places=2, blank=True, null=True, - verbose_name='Channel frequency (MHz)', + verbose_name=_('channel frequency (MHz)'), help_text=_("Populated by selected channel (if set)") ) rf_channel_width = models.DecimalField( @@ -612,26 +636,26 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd decimal_places=3, blank=True, null=True, - verbose_name='Channel width (MHz)', + verbose_name=('channel width (MHz)'), help_text=_("Populated by selected channel (if set)") ) tx_power = models.PositiveSmallIntegerField( blank=True, null=True, validators=(MaxValueValidator(127),), - verbose_name='Transmit power (dBm)' + verbose_name=_('transmit power (dBm)') ) poe_mode = models.CharField( max_length=50, choices=InterfacePoEModeChoices, blank=True, - verbose_name='PoE mode' + verbose_name=_('PoE mode') ) poe_type = models.CharField( max_length=50, choices=InterfacePoETypeChoices, blank=True, - verbose_name='PoE type' + verbose_name=_('PoE type') ) wireless_link = models.ForeignKey( to='wireless.WirelessLink', @@ -644,7 +668,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd to='wireless.WirelessLAN', related_name='interfaces', blank=True, - verbose_name='Wireless LANs' + verbose_name=_('wireless LANs') ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', @@ -652,13 +676,13 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd related_name='interfaces_as_untagged', null=True, blank=True, - verbose_name='Untagged VLAN' + verbose_name=_('untagged VLAN') ) tagged_vlans = models.ManyToManyField( to='ipam.VLAN', related_name='interfaces_as_tagged', blank=True, - verbose_name='Tagged VLANs' + verbose_name=_('tagged VLANs') ) vrf = models.ForeignKey( to='ipam.VRF', @@ -666,7 +690,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd related_name='interfaces', null=True, blank=True, - verbose_name='VRF' + verbose_name=_('VRF') ) ip_addresses = GenericRelation( to='ipam.IPAddress', @@ -704,77 +728,98 @@ def clean(self): # Virtual Interfaces cannot have a Cable attached if self.is_virtual and self.cable: 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 if self.is_virtual and self.mark_connected: 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 # An interface cannot be its own parent 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 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 if self.parent and self.parent.device != self.device: if self.device.virtual_chassis is None: 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: 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 # An interface cannot be bridged to itself 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 if self.bridge and self.bridge.device != self.device: if self.device.virtual_chassis is None: 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: 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 # A virtual interface cannot have a parent LAG 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 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 if self.lag and self.lag.device != self.device: if self.device.virtual_chassis is None: 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: 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 @@ -782,52 +827,54 @@ def clean(self): # Only physical interfaces may have a PoE mode/type assigned if self.poe_mode and self.is_virtual: 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: 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 if self.poe_type and not self.poe_mode: 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 # RF role & channel may only be set for wireless interfaces 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: - 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) if self.rf_channel_frequency: if not self.is_wireless: 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'): 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) if self.rf_channel_width: 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'): - 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 # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: 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): @@ -894,10 +941,12 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): A pass-through port on the front of a Device. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=PortTypeChoices ) color = ColorField( + verbose_name=_('color'), blank=True ) rear_port = models.ForeignKey( @@ -906,6 +955,7 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): related_name='frontports' ) rear_port_position = models.PositiveSmallIntegerField( + verbose_name=_('rear port position'), default=1, validators=[ MinValueValidator(REARPORT_POSITIONS_MIN), @@ -939,14 +989,22 @@ def clean(self): # Validate rear port assignment if self.rear_port.device != self.device: 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 if self.rear_port_position > self.rear_port.positions: 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. """ type = models.CharField( + verbose_name=_('type'), max_length=50, choices=PortTypeChoices ) color = ColorField( + verbose_name=_('color'), blank=True ) positions = models.PositiveSmallIntegerField( + verbose_name=_('positions'), default=1, validators=[ MinValueValidator(REARPORT_POSITIONS_MIN), @@ -982,8 +1043,9 @@ def clean(self): frontport_count = self.frontports.count() if self.positions < frontport_count: 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 """ position = models.CharField( + verbose_name=_('position'), max_length=30, blank=True, help_text=_('Identifier to reference when renaming installed components') @@ -1014,7 +1077,7 @@ class DeviceBay(ComponentModel, TrackingModelMixin): installed_device = models.OneToOneField( to='dcim.Device', on_delete=models.SET_NULL, - related_name='parent_bay', + related_name=_('parent_bay'), blank=True, null=True ) @@ -1029,22 +1092,22 @@ def clean(self): # Validate that the parent Device can have DeviceBays 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 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 if self.installed_device: current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first() if current_bay and current_bay != self: 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. """ color = ColorField( + verbose_name=_('color'), default=ColorChoices.COLOR_GREY ) @@ -1110,13 +1174,13 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): ) part_id = models.CharField( max_length=50, - verbose_name='Part ID', + verbose_name=_('part ID'), blank=True, help_text=_('Manufacturer-assigned part identifier') ) serial = models.CharField( max_length=50, - verbose_name='Serial number', + verbose_name=_('serial number'), blank=True ) asset_tag = models.CharField( @@ -1124,10 +1188,11 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): unique=True, blank=True, null=True, - verbose_name='Asset tag', + verbose_name=_('asset tag'), help_text=_('A unique tag used to identify this item') ) discovered = models.BooleanField( + verbose_name=_('discovered'), default=False, help_text=_('This item was automatically discovered') ) @@ -1154,7 +1219,7 @@ def clean(self): # An InventoryItem cannot be its own parent if self.pk and self.parent_id == self.pk: raise ValidationError({ - "parent": "Cannot assign self as parent." + "parent": _("Cannot assign self as parent.") }) # Validation for moving InventoryItems @@ -1162,13 +1227,13 @@ def clean(self): # Cannot move an InventoryItem to another device if it has a parent if self.parent and self.parent.device != self.device: 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 first_child = self.get_children().first() 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 if self.component and self.component.device != self.device: @@ -1176,5 +1241,5 @@ def clean(self): else: if self.component and self.component.device != self.device: raise ValidationError({ - "device": "Cannot assign inventory item to component on another device" + "device": _("Cannot assign inventory item to component on another device") }) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 66cb6a2526c..cfaaf81642c 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -12,7 +12,7 @@ from django.db.models.signals import post_save from django.urls import reverse 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.constants import * @@ -78,9 +78,11 @@ class DeviceType(PrimaryModel, WeightMixin): related_name='device_types' ) model = models.CharField( + verbose_name=_('model'), max_length=100 ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100 ) default_platform = models.ForeignKey( @@ -89,9 +91,10 @@ class DeviceType(PrimaryModel, WeightMixin): related_name='+', blank=True, null=True, - verbose_name='Default platform' + verbose_name=_('default platform') ) part_number = models.CharField( + verbose_name=_('part number'), max_length=50, blank=True, help_text=_('Discrete part number (optional)') @@ -100,22 +103,23 @@ class DeviceType(PrimaryModel, WeightMixin): max_digits=4, decimal_places=1, default=1.0, - verbose_name='Height (U)' + verbose_name=_('height (U)') ) is_full_depth = models.BooleanField( default=True, - verbose_name='Is full depth', + verbose_name=_('is full depth'), help_text=_('Device consumes both front and rear rack faces') ) subdevice_role = models.CharField( max_length=50, choices=SubdeviceRoleChoices, blank=True, - verbose_name='Parent/child status', + verbose_name=_('parent/child status'), help_text=_('Parent devices house child devices in device bays. Leave blank ' 'if this device type is neither a parent nor a child.') ) airflow = models.CharField( + verbose_name=_('airflow'), max_length=50, choices=DeviceAirflowChoices, blank=True @@ -176,7 +180,8 @@ class DeviceType(PrimaryModel, WeightMixin): ) 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 = ( 'dcim.Manufacturer', @@ -277,7 +282,7 @@ def clean(self): # U height must be divisible by 0.5 if decimal.Decimal(self.u_height) % decimal.Decimal(0.5): 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 @@ -293,8 +298,8 @@ def clean(self): ) if d.position not in u_available: 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. @@ -306,23 +311,23 @@ def clean(self): if racked_instance_count: url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}" raise ValidationError({ - 'u_height': mark_safe( - f'Unable to set 0U height: Found {racked_instance_count} instances already ' - f'mounted within racks.' - ) + 'u_height': mark_safe(_( + 'Unable to set 0U height: Found {racked_instance_count} instances already ' + 'mounted within racks.' + ).format(url=url, racked_instance_count=racked_instance_count)) }) if ( self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT ) and self.pk and self.devicebaytemplates.count(): 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: raise ValidationError({ - 'u_height': "Child device types must be 0U." + 'u_height': _("Child device types must be 0U.") }) def save(self, *args, **kwargs): @@ -367,9 +372,11 @@ class ModuleType(PrimaryModel, WeightMixin): related_name='module_types' ) model = models.CharField( + verbose_name=_('model'), max_length=100 ) part_number = models.CharField( + verbose_name=_('part number'), max_length=50, blank=True, help_text=_('Discrete part number (optional)') @@ -454,11 +461,12 @@ class DeviceRole(OrganizationalModel): virtual machines as well. """ color = ColorField( + verbose_name=_('color'), default=ColorChoices.COLOR_GREY ) vm_role = models.BooleanField( default=True, - verbose_name='VM Role', + verbose_name=_('VM role'), help_text=_('Virtual machines may be assigned to this role') ) config_template = models.ForeignKey( @@ -550,6 +558,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin): null=True ) name = models.CharField( + verbose_name=_('name'), max_length=64, blank=True, null=True @@ -563,7 +572,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin): serial = models.CharField( max_length=50, blank=True, - verbose_name='Serial number', + verbose_name=_('serial number'), help_text=_("Chassis serial number, assigned by the manufacturer") ) asset_tag = models.CharField( @@ -571,7 +580,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin): blank=True, null=True, unique=True, - verbose_name='Asset tag', + verbose_name=_('asset tag'), help_text=_('A unique tag used to identify this device') ) site = models.ForeignKey( @@ -599,21 +608,23 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin): blank=True, null=True, 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') ) face = models.CharField( max_length=50, blank=True, choices=DeviceFaceChoices, - verbose_name='Rack face' + verbose_name=_('rack face') ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=DeviceStatusChoices, default=DeviceStatusChoices.STATUS_ACTIVE ) airflow = models.CharField( + verbose_name=_('airflow'), max_length=50, choices=DeviceAirflowChoices, blank=True @@ -624,7 +635,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin): related_name='+', blank=True, null=True, - verbose_name='Primary IPv4' + verbose_name=_('primary IPv4') ) primary_ip6 = models.OneToOneField( to='ipam.IPAddress', @@ -632,7 +643,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin): related_name='+', blank=True, null=True, - verbose_name='Primary IPv6' + verbose_name=_('primary IPv6') ) oob_ip = models.OneToOneField( to='ipam.IPAddress', @@ -640,7 +651,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin): related_name='+', blank=True, null=True, - verbose_name='Out-of-band IP' + verbose_name=_('out-of-band IP') ) cluster = models.ForeignKey( to='virtualization.Cluster', @@ -657,12 +668,14 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin): null=True ) vc_position = models.PositiveSmallIntegerField( + verbose_name=_('VC position'), blank=True, null=True, validators=[MaxValueValidator(255)], help_text=_('Virtual chassis position') ) vc_priority = models.PositiveSmallIntegerField( + verbose_name=_('VC priority'), blank=True, null=True, validators=[MaxValueValidator(255)], @@ -676,6 +689,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin): null=True ) latitude = models.DecimalField( + verbose_name=_('latitude'), max_digits=8, decimal_places=6, blank=True, @@ -683,6 +697,7 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin): help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") ) longitude = models.DecimalField( + verbose_name=_('longitude'), max_digits=9, decimal_places=6, blank=True, @@ -763,7 +778,7 @@ class Meta: Lower('name'), 'site', name='%(app_label)s_%(class)s_unique_name_site', 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( fields=('rack', 'position', 'face'), @@ -799,42 +814,48 @@ def clean(self): # Validate site/location/rack combination if self.rack and self.site != self.rack.site: 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: 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: 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.face: 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: 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 if self.position and self.position % decimal.Decimal(0.5): 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: 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 if hasattr(self, 'device_type'): if self.position and self.device_type.u_height == 0: 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: @@ -843,13 +864,17 @@ def clean(self): # Child devices cannot be assigned to a rack face/unit if self.device_type.is_child_device and self.face: 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: 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 @@ -860,8 +885,12 @@ def clean(self): ) if self.position and self.position not in available_units: 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: @@ -872,7 +901,7 @@ def clean(self): if self.primary_ip4: if self.primary_ip4.family != 4: 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: pass @@ -880,12 +909,14 @@ def clean(self): pass else: 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.family != 6: 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: pass @@ -893,7 +924,9 @@ def clean(self): pass else: 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.assigned_object in vc_interfaces: @@ -909,20 +942,25 @@ def clean(self): if hasattr(self, 'device_type') and self.platform: if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer: 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) if self.cluster and self.cluster.site is not None and self.cluster.site != self.site: 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 if self.virtual_chassis and self.vc_position is None: 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): @@ -1107,6 +1145,7 @@ class Module(PrimaryModel, ConfigContextModel): related_name='instances' ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=ModuleStatusChoices, default=ModuleStatusChoices.STATUS_ACTIVE @@ -1114,14 +1153,14 @@ class Module(PrimaryModel, ConfigContextModel): serial = models.CharField( max_length=50, blank=True, - verbose_name='Serial number' + verbose_name=_('serial number') ) asset_tag = models.CharField( max_length=50, blank=True, null=True, unique=True, - verbose_name='Asset tag', + verbose_name=_('asset tag'), help_text=_('A unique tag used to identify this device') ) @@ -1144,7 +1183,9 @@ def clean(self): if hasattr(self, "module_bay") and (self.module_bay.device != self.device): 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): @@ -1242,9 +1283,11 @@ class VirtualChassis(PrimaryModel): null=True ) name = models.CharField( + verbose_name=_('name'), max_length=64 ) domain = models.CharField( + verbose_name=_('domain'), max_length=30, blank=True ) @@ -1272,7 +1315,9 @@ def clean(self): # VirtualChassis.) if self.pk and self.master and self.master not in self.members.all(): 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): @@ -1285,10 +1330,10 @@ def delete(self, *args, **kwargs): lag__device=F('device') ) 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) @@ -1302,14 +1347,17 @@ class VirtualDeviceContext(PrimaryModel): null=True ) name = models.CharField( + verbose_name=_('name'), max_length=64 ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=VirtualDeviceContextStatusChoices, ) 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, null=True, ) @@ -1319,7 +1367,7 @@ class VirtualDeviceContext(PrimaryModel): related_name='+', blank=True, null=True, - verbose_name='Primary IPv4' + verbose_name=_('primary IPv4') ) primary_ip6 = models.OneToOneField( to='ipam.IPAddress', @@ -1327,7 +1375,7 @@ class VirtualDeviceContext(PrimaryModel): related_name='+', blank=True, null=True, - verbose_name='Primary IPv6' + verbose_name=_('primary IPv6') ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -1337,6 +1385,7 @@ class VirtualDeviceContext(PrimaryModel): null=True ) comments = models.TextField( + verbose_name=_('comments'), blank=True ) @@ -1382,7 +1431,9 @@ def clean(self): continue if primary_ip.family != family: 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) if primary_ip.assigned_object not in device_interfaces: diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 486945b0f95..f787c8e97b1 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -1,17 +1,20 @@ from django.core.exceptions import ValidationError from django.db import models +from django.utils.translation import gettext_lazy as _ from dcim.choices import * from utilities.utils import to_grams class WeightMixin(models.Model): weight = models.DecimalField( + verbose_name=_('weight'), max_digits=8, decimal_places=2, blank=True, null=True ) weight_unit = models.CharField( + verbose_name=_('weight unit'), max_length=50, choices=WeightUnitChoices, blank=True, @@ -40,4 +43,4 @@ def clean(self): # Validate weight and 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")) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 9b6c08527ec..2c07388d44a 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -3,7 +3,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models 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 netbox.config import ConfigItem @@ -36,6 +36,7 @@ class PowerPanel(PrimaryModel): null=True ) name = models.CharField( + verbose_name=_('name'), max_length=100 ) @@ -72,7 +73,8 @@ def clean(self): # Location must belong to assigned Site if self.location and self.location.site != self.site: 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 ) name = models.CharField( + verbose_name=_('name'), max_length=100 ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=PowerFeedStatusChoices, default=PowerFeedStatusChoices.STATUS_ACTIVE ) type = models.CharField( + verbose_name=_('type'), max_length=50, choices=PowerFeedTypeChoices, default=PowerFeedTypeChoices.TYPE_PRIMARY ) supply = models.CharField( + verbose_name=_('supply'), max_length=50, choices=PowerFeedSupplyChoices, default=PowerFeedSupplyChoices.SUPPLY_AC ) phase = models.CharField( + verbose_name=_('phase'), max_length=50, choices=PowerFeedPhaseChoices, default=PowerFeedPhaseChoices.PHASE_SINGLE ) voltage = models.SmallIntegerField( + verbose_name=_('voltage'), default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'), validators=[ExclusionValidator([0])] ) amperage = models.PositiveSmallIntegerField( + verbose_name=_('amperage'), validators=[MinValueValidator(1)], default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE') ) max_utilization = models.PositiveSmallIntegerField( + verbose_name=_('max utilization'), validators=[MinValueValidator(1), MaxValueValidator(100)], default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'), help_text=_("Maximum permissible draw (percentage)") ) available_power = models.PositiveIntegerField( + verbose_name=_('available power'), default=0, editable=False ) @@ -167,14 +178,14 @@ def clean(self): # Rack must belong to same Site as PowerPanel 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 )) # AC voltage cannot be negative if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC: raise ValidationError({ - "voltage": "Voltage cannot be negative for AC supply" + "voltage": _("Voltage cannot be negative for AC supply") }) def save(self, *args, **kwargs): diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 6d3c15eee84..13fb41b5954 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -9,7 +9,7 @@ from django.db import models from django.db.models import Count 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.constants import * @@ -39,6 +39,7 @@ class RackRole(OrganizationalModel): Racks can be organized by functional role, similar to Devices. """ color = ColorField( + verbose_name=_('color'), default=ColorChoices.COLOR_GREY ) @@ -52,6 +53,7 @@ class Rack(PrimaryModel, WeightMixin): Each Rack is assigned to a Site and (optionally) a Location. """ name = models.CharField( + verbose_name=_('name'), max_length=100 ) _name = NaturalOrderingField( @@ -63,7 +65,7 @@ class Rack(PrimaryModel, WeightMixin): max_length=50, blank=True, null=True, - verbose_name='Facility ID', + verbose_name=_('facility ID'), help_text=_("Locally-assigned identifier") ) site = models.ForeignKey( @@ -86,6 +88,7 @@ class Rack(PrimaryModel, WeightMixin): null=True ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=RackStatusChoices, default=RackStatusChoices.STATUS_ACTIVE @@ -101,60 +104,64 @@ class Rack(PrimaryModel, WeightMixin): serial = models.CharField( max_length=50, blank=True, - verbose_name='Serial number' + verbose_name=_('serial number') ) asset_tag = models.CharField( max_length=50, blank=True, null=True, unique=True, - verbose_name='Asset tag', + verbose_name=_('asset tag'), help_text=_('A unique tag used to identify this rack') ) type = models.CharField( choices=RackTypeChoices, max_length=50, blank=True, - verbose_name='Type' + verbose_name=_('type') ) width = models.PositiveSmallIntegerField( choices=RackWidthChoices, default=RackWidthChoices.WIDTH_19IN, - verbose_name='Width', + verbose_name=_('width'), help_text=_('Rail-to-rail width') ) u_height = models.PositiveSmallIntegerField( default=RACK_U_HEIGHT_DEFAULT, - verbose_name='Height (U)', + verbose_name=_('height (U)'), validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)], help_text=_('Height in rack units') ) starting_unit = models.PositiveSmallIntegerField( default=RACK_STARTING_UNIT_DEFAULT, - verbose_name='Starting unit', + verbose_name=_('starting unit'), help_text=_('Starting unit for rack') ) desc_units = models.BooleanField( default=False, - verbose_name='Descending units', + verbose_name=_('descending units'), help_text=_('Units are numbered top-to-bottom') ) outer_width = models.PositiveSmallIntegerField( + verbose_name=_('outer width'), blank=True, null=True, help_text=_('Outer dimension of rack (width)') ) outer_depth = models.PositiveSmallIntegerField( + verbose_name=_('outer depth'), blank=True, null=True, help_text=_('Outer dimension of rack (depth)') ) outer_unit = models.CharField( + verbose_name=_('outer unit'), max_length=50, choices=RackDimensionUnitChoices, blank=True, ) max_weight = models.PositiveIntegerField( + verbose_name=_('max weight'), blank=True, null=True, help_text=_('Maximum load capacity for the rack') @@ -165,6 +172,7 @@ class Rack(PrimaryModel, WeightMixin): null=True ) mounting_depth = models.PositiveSmallIntegerField( + verbose_name=_('mounting depth'), blank=True, null=True, help_text=( @@ -222,15 +230,15 @@ def clean(self): # Validate location/site assignment 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 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 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: mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position') @@ -240,22 +248,22 @@ def clean(self): min_height = top_device.position + top_device.device_type.u_height - self.starting_unit if self.u_height < min_height: 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 if last_device := mounted_devices.first(): if self.starting_unit > last_device.position: 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 if self.location: if self.location.site != self.site: 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): @@ -504,6 +512,7 @@ class RackReservation(PrimaryModel): related_name='reservations' ) units = ArrayField( + verbose_name=_('units'), base_field=models.PositiveSmallIntegerField() ) tenant = models.ForeignKey( @@ -518,6 +527,7 @@ class RackReservation(PrimaryModel): on_delete=models.PROTECT ) description = models.CharField( + verbose_name=_('description'), max_length=200 ) @@ -544,7 +554,7 @@ def clean(self): invalid_units = [u for u in self.units if u not in self.rack.units] if invalid_units: raise ValidationError({ - 'units': "Invalid unit(s) for {}U rack: {}".format( + 'units': _("Invalid unit(s) for {}U rack: {}").format( self.rack.u_height, ', '.join([str(u) for u in invalid_units]), ), @@ -557,7 +567,7 @@ def clean(self): conflicting_units = [u for u in self.units if u in reserved_units] if conflicting_units: 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]), ) }) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 3bd434648b6..30d96e67da6 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from django.db import models 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 dcim.choices import * @@ -49,7 +49,7 @@ class Meta: fields=('name',), name='%(app_label)s_%(class)s_name', 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( fields=('parent', 'slug'), @@ -59,7 +59,7 @@ class Meta: fields=('slug',), name='%(app_label)s_%(class)s_slug', 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 Meta: fields=('name',), name='%(app_label)s_%(class)s_name', 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( fields=('parent', 'slug'), @@ -114,7 +114,7 @@ class Meta: fields=('slug',), name='%(app_label)s_%(class)s_slug', 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). """ name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True, help_text=_("Full name of the site") @@ -148,10 +149,12 @@ class Site(PrimaryModel): blank=True ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100, unique=True ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=SiteStatusChoices, default=SiteStatusChoices.STATUS_ACTIVE @@ -178,9 +181,10 @@ class Site(PrimaryModel): null=True ) facility = models.CharField( + verbose_name=_('facility'), max_length=50, blank=True, - help_text=_("Local facility ID or description") + help_text=_('Local facility ID or description') ) asns = models.ManyToManyField( to='ipam.ASN', @@ -191,28 +195,32 @@ class Site(PrimaryModel): blank=True ) physical_address = models.CharField( + verbose_name=_('physical address'), max_length=200, blank=True, - help_text=_("Physical location of the building") + help_text=_('Physical location of the building') ) shipping_address = models.CharField( + verbose_name=_('shipping address'), max_length=200, blank=True, - help_text=_("If different from the physical address") + help_text=_('If different from the physical address') ) latitude = models.DecimalField( + verbose_name=_('latitude'), max_digits=8, decimal_places=6, blank=True, null=True, - help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") + help_text=_('GPS coordinate in decimal format (xx.yyyyyy)') ) longitude = models.DecimalField( + verbose_name=_('longitude'), max_digits=9, decimal_places=6, blank=True, null=True, - help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") + help_text=_('GPS coordinate in decimal format (xx.yyyyyy)') ) # Generic relations @@ -262,6 +270,7 @@ class Location(NestedGroupModel): related_name='locations' ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=LocationStatusChoices, default=LocationStatusChoices.STATUS_ACTIVE @@ -304,7 +313,7 @@ class Meta: fields=('site', 'name'), name='%(app_label)s_%(class)s_name', 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( fields=('site', 'parent', 'slug'), @@ -314,7 +323,7 @@ class Meta: fields=('site', 'slug'), name='%(app_label)s_%(class)s_slug', 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 @@ def clean(self): # Parent Location (if any) must belong to the same 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)) diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 444701acc16..f38533f8036 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from extras.choices import * 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. """ time = models.DateTimeField( + verbose_name=_('time'), auto_now_add=True, editable=False, db_index=True @@ -31,14 +33,17 @@ class ObjectChange(models.Model): null=True ) user_name = models.CharField( + verbose_name=_('user name'), max_length=150, editable=False ) request_id = models.UUIDField( + verbose_name=_('request ID'), editable=False, db_index=True ) action = models.CharField( + verbose_name=_('action'), max_length=50, choices=ObjectChangeActionChoices ) @@ -72,11 +77,13 @@ class ObjectChange(models.Model): editable=False ) prechange_data = models.JSONField( + verbose_name=_('pre-change data'), editable=False, blank=True, null=True ) postchange_data = models.JSONField( + verbose_name=_('post-change data'), editable=False, blank=True, null=True diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index ee9f7cfda74..3ff51f3933c 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -2,7 +2,7 @@ from django.core.validators import ValidationError from django.db import models 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.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. """ name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) weight = models.PositiveSmallIntegerField( + verbose_name=_('weight'), default=1000 ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) is_active = models.BooleanField( + verbose_name=_('is active'), default=True, ) regions = models.ManyToManyField( @@ -138,7 +142,7 @@ def clean(self): # Verify that JSON data is provided as an object if type(self.data) is not dict: 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): @@ -194,7 +198,7 @@ def clean(self): # Verify that JSON data is provided as an object if self.local_context_data and type(self.local_context_data) is not dict: 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 @@ def clean(self): class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): name = models.CharField( + verbose_name=_('name'), max_length=100 ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) template_code = models.TextField( + verbose_name=_('template code'), help_text=_('Jinja2 template code.') ) environment_params = models.JSONField( + verbose_name=_('environment parameters'), blank=True, null=True, default=dict, diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 28bda6fbf24..5b64d8cc522 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -12,7 +12,7 @@ from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from extras.choices import * 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.') ) type = models.CharField( + verbose_name=_('type'), max_length=50, choices=CustomFieldTypeChoices, 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)') ) name = models.CharField( + verbose_name=_('name'), max_length=50, unique=True, help_text=_('Internal field name'), validators=( RegexValidator( regex=r'^[a-z0-9_]+$', - message="Only alphanumeric characters and underscores are allowed.", + message=_("Only alphanumeric characters and underscores are allowed."), flags=re.IGNORECASE ), RegexValidator( 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, inverse_match=True ), ) ) label = models.CharField( + verbose_name=_('label'), max_length=50, 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( + verbose_name=_('group name'), max_length=50, blank=True, help_text=_("Custom fields within the same group will be displayed together") ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) required = models.BooleanField( + verbose_name=_('required'), 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( + verbose_name=_('search weight'), 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( + verbose_name=_('filter logic'), max_length=50, choices=CustomFieldFilterLogicChoices, 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( + verbose_name=_('default'), blank=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( default=100, - verbose_name='Display weight', + verbose_name=_('display weight'), help_text=_('Fields with higher weights appear lower in a form.') ) validation_minimum = models.IntegerField( blank=True, null=True, - verbose_name='Minimum value', + verbose_name=_('minimum value'), help_text=_('Minimum allowed value (for numeric fields)') ) validation_maximum = models.IntegerField( blank=True, null=True, - verbose_name='Maximum value', + verbose_name=_('maximum value'), help_text=_('Maximum allowed value (for numeric fields)') ) validation_regex = models.CharField( blank=True, validators=[validate_regex], max_length=500, - verbose_name='Validation regex', + verbose_name=_('validation regex'), help_text=_( 'Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. For ' 'example, ^[A-Z]{3}$ will limit values to exactly three uppercase letters.' @@ -164,6 +175,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): to='CustomFieldChoiceSet', on_delete=models.PROTECT, related_name='choices_for', + verbose_name=_('choice set'), blank=True, null=True ) @@ -171,12 +183,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): max_length=50, choices=CustomFieldVisibilityChoices, default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, - verbose_name='UI visibility', + verbose_name=_('UI visibility'), help_text=_('Specifies the visibility of custom field in the UI') ) is_cloneable = models.BooleanField( default=False, - verbose_name='Cloneable', + verbose_name=_('is cloneable'), help_text=_('Replicate this value when cloning objects') ) @@ -266,15 +278,17 @@ def clean(self): self.validate(default_value) except ValidationError as err: 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 if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL): 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: - 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_types = ( @@ -284,7 +298,7 @@ def clean(self): ) if self.validation_regex and self.type not in regex_types: 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 @@ -294,28 +308,32 @@ def clean(self): ): if not self.choice_set: 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: 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 if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices: 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 if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT): if not self.object_type: raise ValidationError({ - 'object_type': "Object fields must define an object type." + 'object_type': _("Object fields must define an object type.") }) elif self.object_type: 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): @@ -394,8 +412,8 @@ def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibil elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: choices = ( (None, '---------'), - (True, 'True'), - (False, 'False'), + (True, _('True')), + (False, _('False')), ) field = forms.NullBooleanField( required=required, initial=initial, widget=forms.Select(choices=choices) @@ -470,7 +488,9 @@ def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibil field.validators = [ RegexValidator( regex=self.validation_regex, - message=mark_safe(f"Values must match this regex: {self.validation_regex}") + message=mark_safe(_("Values must match this regex: {regex}").format( + regex=self.validation_regex + )) ) ] @@ -483,7 +503,7 @@ def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibil if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: field.disabled = True prepend = '
' if field.help_text else '' - field.help_text += f'{prepend} Field is set to read-only.' + field.help_text += f'{prepend} ' + _('Field is set to read-only.') return field @@ -565,33 +585,41 @@ def validate(self, value): # Validate text field if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT): 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): - raise ValidationError(f"Value must match regex '{self.validation_regex}'") + raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex)) # Validate integer elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: 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: - 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: - raise ValidationError(f"Value must not exceed {self.validation_maximum}") + raise ValidationError( + _("Value must not exceed {maximum}").format(maximum=self.validation_maximum) + ) # Validate decimal elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: try: decimal.Decimal(value) 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: - 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: - raise ValidationError(f"Value must not exceed {self.validation_maximum}") + raise ValidationError( + _("Value must not exceed {maximum}").format(maximum=self.validation_maximum) + ) # Validate boolean 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 elif self.type == CustomFieldTypeChoices.TYPE_DATE: @@ -599,7 +627,7 @@ def validate(self, value): try: date.fromisoformat(value) 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 elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: @@ -607,37 +635,44 @@ def validate(self, value): try: datetime.fromisoformat(value) 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 elif self.type == CustomFieldTypeChoices.TYPE_SELECT: if value not in [c[0] for c in self.choices]: 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 elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: if not set(value).issubset([c[0] for c in self.choices]): 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 elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: 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 elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: 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: 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: - raise ValidationError("Required field cannot be empty.") + raise ValidationError(_("Required field cannot be empty.")) class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): diff --git a/netbox/extras/models/dashboard.py b/netbox/extras/models/dashboard.py index a0d300ff6df..33bb735c43a 100644 --- a/netbox/extras/models/dashboard.py +++ b/netbox/extras/models/dashboard.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.db import models +from django.utils.translation import gettext_lazy as _ from extras.dashboard.utils import get_widget_class @@ -15,9 +16,11 @@ class Dashboard(models.Model): related_name='dashboard' ) layout = models.JSONField( + verbose_name=_('layout'), default=list ) config = models.JSONField( + verbose_name=_('config'), default=dict ) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 193d3af6a5b..b209f0d5bd8 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -12,7 +12,7 @@ from django.urls import reverse from django.utils import timezone 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 extras.choices import * @@ -48,93 +48,113 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='webhooks', - verbose_name='Object types', + verbose_name=_('object types'), limit_choices_to=FeatureQuery('webhooks'), help_text=_("The object(s) to which this Webhook applies.") ) name = models.CharField( + verbose_name=_('name'), max_length=150, unique=True ) type_create = models.BooleanField( + verbose_name=_('on create'), default=False, help_text=_("Triggers when a matching object is created.") ) type_update = models.BooleanField( + verbose_name=_('on update'), default=False, help_text=_("Triggers when a matching object is updated.") ) type_delete = models.BooleanField( + verbose_name=_('on delete'), default=False, help_text=_("Triggers when a matching object is deleted.") ) type_job_start = models.BooleanField( + verbose_name=_('on job start'), default=False, help_text=_("Triggers when a job for a matching object is started.") ) type_job_end = models.BooleanField( + verbose_name=_('on job end'), default=False, help_text=_("Triggers when a job for a matching object terminates.") ) payload_url = models.CharField( 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( + verbose_name=_('enabled'), default=True ) http_method = models.CharField( max_length=30, choices=WebhookHttpMethodChoices, default=WebhookHttpMethodChoices.METHOD_POST, - verbose_name='HTTP method' + verbose_name=_('HTTP method') ) http_content_type = models.CharField( max_length=100, default=HTTP_CONTENT_TYPE_JSON, - verbose_name='HTTP content type', - help_text=_('The complete list of official content types is available ' - 'here.') + verbose_name=_('HTTP content type'), + help_text=_( + 'The complete list of official content types is available ' + 'here.' + ) ) additional_headers = models.TextField( + verbose_name=_('additional headers'), 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 Name: Value. 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 Name: Value. Jinja2 template processing is supported with " + "the same context as the request body (below)." + ) ) body_template = models.TextField( + verbose_name=_('body template'), 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: event, model, ' - 'timestamp, username, request_id, and data.') + help_text=_( + "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be " + "included. Available context data includes: event, model, " + "timestamp, username, request_id, and data." + ) ) secret = models.CharField( + verbose_name=_('secret'), max_length=255, 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 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." + ) ) conditions = models.JSONField( + verbose_name=_('conditions'), blank=True, null=True, help_text=_("A set of conditions which determine whether the webhook will be generated.") ) ssl_verification = models.BooleanField( default=True, - verbose_name='SSL verification', + verbose_name=_('SSL verification'), help_text=_("Enable SSL certificate verification. Disable with caution!") ) ca_file_path = models.CharField( max_length=4096, null=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: @@ -164,7 +184,7 @@ def clean(self): self.type_create, self.type_update, self.type_delete, self.type_job_start, self.type_job_end ]): 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: @@ -176,7 +196,7 @@ def clean(self): # CA file path requires SSL verification enabled if not self.ssl_verification and self.ca_file_path: 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): @@ -219,34 +239,41 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): help_text=_('The object type(s) to which this link applies.') ) name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) enabled = models.BooleanField( + verbose_name=_('enabled'), default=True ) link_text = models.TextField( + verbose_name=_('link text'), help_text=_("Jinja2 template code for link text") ) link_url = models.TextField( - verbose_name='Link URL', + verbose_name=_('link URL'), help_text=_("Jinja2 template code for link URL") ) weight = models.PositiveSmallIntegerField( + verbose_name=_('weight'), default=100 ) group_name = models.CharField( + verbose_name=_('group name'), max_length=50, blank=True, help_text=_("Links with the same group will appear as a dropdown menu") ) button_class = models.CharField( + verbose_name=_('button class'), max_length=30, choices=CustomLinkButtonClassChoices, default=CustomLinkButtonClassChoices.DEFAULT, help_text=_("The class of the first link in a group will be used for the dropdown button") ) new_window = models.BooleanField( + verbose_name=_('new window'), default=False, 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.') ) name = models.CharField( + verbose_name=_('name'), max_length=100 ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) template_code = models.TextField( - help_text=_('Jinja2 template code. The list of objects being exported is passed as a context variable named ' - 'queryset.') + help_text=_( + "Jinja2 template code. The list of objects being exported is passed as a context variable named " + "queryset." + ) ) mime_type = models.CharField( max_length=50, blank=True, - verbose_name='MIME type', + verbose_name=_('MIME type'), help_text=_('Defaults to text/plain; charset=utf-8') ) file_extension = models.CharField( + verbose_name=_('file extension'), max_length=15, blank=True, help_text=_('Extension to append to the rendered filename') ) as_attachment = models.BooleanField( + verbose_name=_('as attachment'), default=True, help_text=_("Download file as attachment") ) @@ -354,7 +387,7 @@ def clean(self): if self.name.lower() == 'table': 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): @@ -407,14 +440,17 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): help_text=_('The object type(s) to which this filter applies.') ) name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100, unique=True ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) @@ -425,15 +461,20 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): null=True ) weight = models.PositiveSmallIntegerField( + verbose_name=_('weight'), default=100 ) enabled = models.BooleanField( + verbose_name=_('enabled'), default=True ) shared = models.BooleanField( + verbose_name=_('shared'), default=True ) - parameters = models.JSONField() + parameters = models.JSONField( + verbose_name=_('parameters') + ) clone_fields = ( 'content_types', 'weight', 'enabled', 'parameters', @@ -458,7 +499,7 @@ def clean(self): # Verify that `parameters` is a JSON object if type(self.parameters) is not dict: 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 @@ -485,9 +526,14 @@ class ImageAttachment(ChangeLoggedModel): height_field='image_height', 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( + verbose_name=_('name'), max_length=50, blank=True ) @@ -565,11 +611,14 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat null=True ) kind = models.CharField( + verbose_name=_('kind'), max_length=30, choices=JournalEntryKindChoices, default=JournalEntryKindChoices.KIND_INFO ) - comments = models.TextField() + comments = models.TextField( + verbose_name=_('comments'), + ) class Meta: ordering = ('-created',) @@ -588,7 +637,9 @@ def clean(self): # Prevent the creation of journal entries on unsupported models permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query()) 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): return JournalEntryKindChoices.colors.get(self.kind) @@ -599,6 +650,7 @@ class Bookmark(models.Model): An object bookmarked by a User. """ created = models.DateTimeField( + verbose_name=_('created'), auto_now_add=True ) object_type = models.ForeignKey( @@ -637,16 +689,18 @@ class ConfigRevision(models.Model): An atomic revision of NetBox's configuration. """ created = models.DateTimeField( + verbose_name=_('created'), auto_now_add=True ) comment = models.CharField( + verbose_name=_('comment'), max_length=200, blank=True ) data = models.JSONField( blank=True, null=True, - verbose_name='Configuration data' + verbose_name=_('configuration data') ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py index 6d088abb032..052b43f890b 100644 --- a/netbox/extras/models/search.py +++ b/netbox/extras/models/search.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import models +from django.utils.translation import gettext_lazy as _ from utilities.fields import RestrictedGenericForeignKey from ..fields import CachedValueField @@ -18,6 +19,7 @@ class CachedValue(models.Model): editable=False ) timestamp = models.DateTimeField( + verbose_name=_('timestamp'), auto_now_add=True, editable=False ) @@ -32,13 +34,18 @@ class CachedValue(models.Model): fk_field='object_id' ) field = models.CharField( + verbose_name=_('field'), max_length=200 ) type = models.CharField( + verbose_name=_('type'), max_length=30 ) - value = CachedValueField() + value = CachedValueField( + verbose_name=_('value'), + ) weight = models.PositiveSmallIntegerField( + verbose_name=_('weight'), default=1000 ) diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index 850015be773..cb0c6e704cd 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models, transaction +from django.utils.translation import gettext_lazy as _ from extras.choices import ChangeActionChoices from netbox.models import ChangeLoggedModel @@ -22,10 +23,12 @@ class Branch(ChangeLoggedModel): A collection of related StagedChanges. """ name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) @@ -61,6 +64,7 @@ class StagedChange(ChangeLoggedModel): related_name='staged_changes' ) action = models.CharField( + verbose_name=_('action'), max_length=20, choices=ChangeActionChoices ) @@ -78,6 +82,7 @@ class StagedChange(ChangeLoggedModel): fk_field='object_id' ) data = models.JSONField( + verbose_name=_('data'), blank=True, null=True ) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index f54b3d0fef0..3b19f62d931 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -4,7 +4,7 @@ from django.db import models from django.urls import reverse 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 extras.utils import FeatureQuery @@ -28,9 +28,11 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): primary_key=True ) color = ColorField( + verbose_name=_('color'), default=ColorChoices.COLOR_GREY ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True, ) diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py index 6c0b5231b9a..57499c676c9 100644 --- a/netbox/ipam/models/asns.py +++ b/netbox/ipam/models/asns.py @@ -1,7 +1,7 @@ from django.core.exceptions import ValidationError from django.db import models 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.querysets import ASNRangeQuerySet @@ -15,10 +15,12 @@ class ASNRange(OrganizationalModel): name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100, unique=True ) @@ -26,10 +28,14 @@ class ASNRange(OrganizationalModel): to='ipam.RIR', on_delete=models.PROTECT, 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( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -62,7 +68,11 @@ def clean(self): super().clean() 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): return ASN.objects.filter( @@ -90,12 +100,12 @@ class ASN(PrimaryModel): to='ipam.RIR', on_delete=models.PROTECT, related_name='asns', - verbose_name='RIR', + verbose_name=_('RIR'), help_text=_("Regional Internet Registry responsible for this AS number space") ) asn = ASNField( unique=True, - verbose_name='ASN', + verbose_name=_('ASN'), help_text=_('16- or 32-bit autonomous system number') ) tenant = models.ForeignKey( diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 1044a5cde30..78c34db6a61 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -3,6 +3,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from netbox.models import ChangeLoggedModel, PrimaryModel 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.) """ group_id = models.PositiveSmallIntegerField( - verbose_name='Group ID' + verbose_name=_('group ID') ) name = models.CharField( + verbose_name=_('name'), max_length=100, blank=True ) protocol = models.CharField( + verbose_name=_('protocol'), max_length=50, choices=FHRPGroupProtocolChoices ) @@ -33,12 +36,12 @@ class FHRPGroup(PrimaryModel): max_length=50, choices=FHRPGroupAuthTypeChoices, blank=True, - verbose_name='Authentication type' + verbose_name=_('authentication type') ) auth_key = models.CharField( max_length=255, blank=True, - verbose_name='Authentication key' + verbose_name=_('authentication key') ) ip_addresses = GenericRelation( to='ipam.IPAddress', @@ -87,6 +90,7 @@ class FHRPGroupAssignment(ChangeLoggedModel): on_delete=models.CASCADE ) priority = models.PositiveSmallIntegerField( + verbose_name=_('priority'), validators=( MinValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MIN), MaxValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MAX) @@ -103,7 +107,7 @@ class Meta: name='%(app_label)s_%(class)s_unique_interface_group' ), ) - verbose_name = 'FHRP group assignment' + verbose_name = _('FHRP group assignment') def __str__(self): return f'{self.interface}: {self.group} ({self.priority})' diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index a5d6eb08441..eba71bb45b5 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -6,7 +6,7 @@ from django.db.models import F from django.urls import reverse 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.constants import * @@ -59,14 +59,14 @@ class RIR(OrganizationalModel): """ is_private = models.BooleanField( default=False, - verbose_name='Private', + verbose_name=_('private'), help_text=_('IP space managed by this RIR is considered private') ) class Meta: ordering = ('name',) - verbose_name = 'RIR' - verbose_name_plural = 'RIRs' + verbose_name = _('RIR') + verbose_name_plural = _('RIRs') def get_absolute_url(self): return reverse('ipam:rir', args=[self.pk]) @@ -84,7 +84,7 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): to='ipam.RIR', on_delete=models.PROTECT, related_name='aggregates', - verbose_name='RIR', + verbose_name=_('RIR'), help_text=_("Regional Internet Registry responsible for this IP space") ) tenant = models.ForeignKey( @@ -95,6 +95,7 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): null=True ) date_added = models.DateField( + verbose_name=_('date added'), blank=True, null=True ) @@ -123,7 +124,7 @@ def clean(self): # /0 masks are not acceptable if self.prefix.prefixlen == 0: 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 @@ -134,9 +135,9 @@ def clean(self): covering_aggregates = covering_aggregates.exclude(pk=self.pk) if covering_aggregates: 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 @@ -145,7 +146,7 @@ def clean(self): covered_aggregates = covered_aggregates.exclude(pk=self.pk) if covered_aggregates: 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] ) }) @@ -179,6 +180,7 @@ class Role(OrganizationalModel): "Management." """ weight = models.PositiveSmallIntegerField( + verbose_name=_('weight'), default=1000 ) @@ -199,6 +201,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): assigned to a VLAN where appropriate. """ prefix = IPNetworkField( + verbose_name=_('prefix'), help_text=_('IPv4 or IPv6 network with mask') ) site = models.ForeignKey( @@ -214,7 +217,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): related_name='prefixes', blank=True, null=True, - verbose_name='VRF' + verbose_name=_('VRF') ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -228,14 +231,13 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): on_delete=models.PROTECT, related_name='prefixes', blank=True, - null=True, - verbose_name='VLAN' + null=True ) status = models.CharField( max_length=50, choices=PrefixStatusChoices, default=PrefixStatusChoices.STATUS_ACTIVE, - verbose_name='Status', + verbose_name=_('status'), help_text=_('Operational status of this prefix') ) role = models.ForeignKey( @@ -247,11 +249,12 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): help_text=_('The primary function of this prefix') ) is_pool = models.BooleanField( - verbose_name='Is a pool', + verbose_name=_('is a pool'), default=False, help_text=_('All IP addresses within this prefix are considered usable') ) mark_utilized = models.BooleanField( + verbose_name=_('mark utilized'), default=False, help_text=_("Treat as 100% utilized") ) @@ -297,7 +300,7 @@ def clean(self): # /0 masks are not acceptable if self.prefix.prefixlen == 0: raise ValidationError({ - 'prefix': "Cannot create prefix with /0 mask." + 'prefix': _("Cannot create prefix with /0 mask.") }) # Enforce unique IP space (if applicable) @@ -305,8 +308,8 @@ def clean(self): duplicate_prefixes = self.get_duplicates() if duplicate_prefixes: 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(), ) }) @@ -474,12 +477,15 @@ class IPRange(PrimaryModel): A range of IP addresses, defined by start and end addresses. """ start_address = IPAddressField( + verbose_name=_('start address'), help_text=_('IPv4 or IPv6 address (with mask)') ) end_address = IPAddressField( + verbose_name=_('end address'), help_text=_('IPv4 or IPv6 address (with mask)') ) size = models.PositiveIntegerField( + verbose_name=_('size'), editable=False ) vrf = models.ForeignKey( @@ -488,7 +494,7 @@ class IPRange(PrimaryModel): related_name='ip_ranges', blank=True, null=True, - verbose_name='VRF' + verbose_name=_('VRF') ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -498,6 +504,7 @@ class IPRange(PrimaryModel): null=True ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=IPRangeStatusChoices, default=IPRangeStatusChoices.STATUS_ACTIVE, @@ -512,6 +519,7 @@ class IPRange(PrimaryModel): help_text=_('The primary function of this range') ) mark_utilized = models.BooleanField( + verbose_name=_('mark utilized'), default=False, help_text=_("Treat as 100% utilized") ) @@ -539,21 +547,33 @@ def clean(self): # Check that start & end IP versions match if self.start_address.version != self.end_address.version: 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 if self.start_address.prefixlen != self.end_address.prefixlen: 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 if not self.end_address > self.start_address: 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 @@ -563,12 +583,18 @@ def clean(self): Q(start_address__lte=self.start_address, end_address__gte=self.end_address) # Starts & ends outside ).first() 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 MAX_SIZE = 2 ** 32 - 1 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): @@ -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. """ address = IPAddressField( + verbose_name=_('address'), help_text=_('IPv4 or IPv6 address (with mask)') ) vrf = models.ForeignKey( @@ -687,7 +714,7 @@ class IPAddress(PrimaryModel): related_name='ip_addresses', blank=True, null=True, - verbose_name='VRF' + verbose_name=_('VRF') ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -697,12 +724,14 @@ class IPAddress(PrimaryModel): null=True ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=IPAddressStatusChoices, default=IPAddressStatusChoices.STATUS_ACTIVE, help_text=_('The operational status of this IP') ) role = models.CharField( + verbose_name=_('role'), max_length=50, choices=IPAddressRoleChoices, blank=True, @@ -730,14 +759,14 @@ class IPAddress(PrimaryModel): related_name='nat_outside', blank=True, null=True, - verbose_name='NAT (Inside)', + verbose_name=_('NAT (inside)'), help_text=_('The IP for which this address is the "outside" IP') ) dns_name = models.CharField( max_length=255, blank=True, validators=[DNSValidator], - verbose_name='DNS Name', + verbose_name=_('DNS name'), help_text=_('Hostname or FQDN (not case-sensitive)') ) @@ -799,7 +828,7 @@ def clean(self): # /0 masks are not acceptable if self.address.prefixlen == 0: raise ValidationError({ - 'address': "Cannot create IP address with /0 mask." + 'address': _("Cannot create IP address with /0 mask.") }) # Enforce unique IP space (if applicable) @@ -810,8 +839,8 @@ def clean(self): any(dip.role not in IPADDRESS_ROLES_NONUNIQUE for dip in duplicate_ips) ): 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(), ) }) @@ -819,7 +848,7 @@ def clean(self): # Validate IP status selection if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6: 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): diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index c858d1a0c87..6234f3eea3d 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -4,6 +4,7 @@ from django.db import models from django.urls import reverse from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ from ipam.choices import L2VPNTypeChoices from ipam.constants import L2VPN_ASSIGNMENT_MODELS @@ -17,18 +18,22 @@ class L2VPN(PrimaryModel): name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100, unique=True ) type = models.CharField( + verbose_name=_('type'), max_length=50, choices=L2VPNTypeChoices ) identifier = models.BigIntegerField( + verbose_name=_('identifier'), null=True, blank=True ) @@ -123,7 +128,11 @@ def clean(self): obj_type = ContentType.objects.get_for_model(self.assigned_object) if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\ 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 if hasattr(self, 'l2vpn') and self.l2vpn.type in L2VPNTypeChoices.P2P: @@ -131,9 +140,10 @@ def clean(self): if terminations_count >= 2: l2vpn_type = self.l2vpn.get_type_display() 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 def assigned_object_parent(self): diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 47ba3b7dc0c..277b383a588 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -3,7 +3,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models 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.constants import * @@ -19,6 +19,7 @@ class ServiceBase(models.Model): protocol = models.CharField( + verbose_name=_('protocol'), max_length=50, choices=ServiceProtocolChoices ) @@ -29,7 +30,7 @@ class ServiceBase(models.Model): MaxValueValidator(SERVICE_PORT_MAX) ] ), - verbose_name='Port numbers' + verbose_name=_('port numbers') ) class Meta: @@ -48,6 +49,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel): A template for a Service to be applied to a device or virtual machine. """ name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) @@ -68,7 +70,7 @@ class Service(ServiceBase, PrimaryModel): to='dcim.Device', on_delete=models.CASCADE, related_name='services', - verbose_name='device', + verbose_name=_('device'), null=True, blank=True ) @@ -80,13 +82,14 @@ class Service(ServiceBase, PrimaryModel): blank=True ) name = models.CharField( - max_length=100 + max_length=100, + verbose_name=_('name') ) ipaddresses = models.ManyToManyField( to='ipam.IPAddress', related_name='services', blank=True, - verbose_name='IP addresses', + verbose_name=_('IP addresses'), help_text=_("The specific IP addresses (if any) to which this service is bound") ) @@ -107,6 +110,6 @@ def clean(self): # A Service must belong to a Device *or* to a VirtualMachine 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: - 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.")) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index da504ded2ac..3b9bd009526 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -4,7 +4,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models 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 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. """ name = models.CharField( + verbose_name=_('name'), max_length=100 ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100 ) scope_type = models.ForeignKey( @@ -45,7 +47,7 @@ class VLANGroup(OrganizationalModel): fk_field='scope_id' ) min_vid = models.PositiveSmallIntegerField( - verbose_name='Minimum VLAN ID', + verbose_name=_('minimum VLAN ID'), default=VLAN_VID_MIN, validators=( MinValueValidator(VLAN_VID_MIN), @@ -54,7 +56,7 @@ class VLANGroup(OrganizationalModel): help_text=_('Lowest permissible ID of a child VLAN') ) max_vid = models.PositiveSmallIntegerField( - verbose_name='Maximum VLAN ID', + verbose_name=_('maximum VLAN ID'), default=VLAN_VID_MAX, validators=( MinValueValidator(VLAN_VID_MIN), @@ -88,14 +90,14 @@ def clean(self): # Validate scope assignment 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: - raise ValidationError("Cannot set scope_id without scope_type.") + raise ValidationError(_("Cannot set scope_id without scope_type.")) # Validate min/max child VID limits if self.max_vid < self.min_vid: 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): @@ -143,7 +145,7 @@ class VLAN(PrimaryModel): help_text=_("VLAN group (optional)") ) vid = models.PositiveSmallIntegerField( - verbose_name='ID', + verbose_name=_('VLAN ID'), validators=( MinValueValidator(VLAN_VID_MIN), MaxValueValidator(VLAN_VID_MAX) @@ -151,6 +153,7 @@ class VLAN(PrimaryModel): help_text=_("Numeric VLAN ID (1-4094)") ) name = models.CharField( + verbose_name=_('name'), max_length=64 ) tenant = models.ForeignKey( @@ -161,6 +164,7 @@ class VLAN(PrimaryModel): null=True ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=VLANStatusChoices, default=VLANStatusChoices.STATUS_ACTIVE, @@ -215,15 +219,17 @@ def clean(self): # Validate VLAN group (if assigned) if self.group and self.site and self.group.scope != self.site: 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 if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid: 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): diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index a1a53b3a73e..d338e2dcd45 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -1,6 +1,6 @@ from django.db import models 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 netbox.models import PrimaryModel @@ -19,6 +19,7 @@ class VRF(PrimaryModel): are said to exist in the "global" table.) """ name = models.CharField( + verbose_name=_('name'), max_length=100 ) rd = models.CharField( @@ -26,7 +27,7 @@ class VRF(PrimaryModel): unique=True, blank=True, null=True, - verbose_name='Route distinguisher', + verbose_name=_('route distinguisher'), help_text=_('Unique route distinguisher (as defined in RFC 4364)') ) tenant = models.ForeignKey( @@ -38,7 +39,7 @@ class VRF(PrimaryModel): ) enforce_unique = models.BooleanField( default=True, - verbose_name='Enforce unique space', + verbose_name=_('enforce unique space'), help_text=_('Prevent duplicate prefixes/IP addresses within this VRF') ) 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. """ name = models.CharField( + verbose_name=_('name'), max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) unique=True, help_text=_('Route target value (formatted in accordance with RFC 4360)') diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 23dcfb9859c..931d565ba3f 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.core.validators import ValidationError from django.db import models +from django.utils.translation import gettext_lazy as _ from mptt.models import MPTTModel, TreeForeignKey from netbox.models.features import * @@ -94,10 +95,12 @@ class PrimaryModel(NetBoxModel): Primary models represent real objects within the infrastructure being modeled. """ description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) comments = models.TextField( + verbose_name=_('comments'), blank=True ) @@ -119,12 +122,15 @@ class NestedGroupModel(CloningMixin, NetBoxFeatureSet, MPTTModel): db_index=True ) name = models.CharField( + verbose_name=_('name'), max_length=100 ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100 ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) @@ -146,7 +152,7 @@ def clean(self): # An MPTT model cannot be its own parent if self.pk and self.parent and self.parent in self.get_descendants(include_self=True): 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 """ name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100, unique=True ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index e07857145a5..d5228ed639b 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -9,7 +9,7 @@ from django.db.models.signals import class_prepared from django.dispatch import receiver 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 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. """ created = models.DateTimeField( + verbose_name=_('created'), auto_now_add=True, blank=True, null=True ) last_updated = models.DateTimeField( + verbose_name=_('last updated'), auto_now=True, blank=True, null=True @@ -401,16 +403,19 @@ class SyncedDataMixin(models.Model): related_name='+' ) data_path = models.CharField( + verbose_name=_('data path'), max_length=1000, blank=True, editable=False, help_text=_("Path to remote file (relative to data source root)") ) auto_sync_enabled = models.BooleanField( + verbose_name=_('auto sync enabled'), default=False, help_text=_("Enable automatic synchronization of data when the data file is updated") ) data_synced = models.DateTimeField( + verbose_name=_('date synced'), blank=True, null=True, editable=False diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 1df5e3305da..f3440c25d6f 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from tenancy.choices import * @@ -51,24 +52,30 @@ class Contact(PrimaryModel): null=True ) name = models.CharField( + verbose_name=_('name'), max_length=100 ) title = models.CharField( + verbose_name=_('title'), max_length=100, blank=True ) phone = models.CharField( + verbose_name=_('phone'), max_length=50, blank=True ) email = models.EmailField( + verbose_name=_('email'), blank=True ) address = models.CharField( + verbose_name=_('address'), max_length=200, blank=True ) link = models.URLField( + verbose_name=_('link'), blank=True ) @@ -113,6 +120,7 @@ class ContactAssignment(ChangeLoggedModel): related_name='assignments' ) priority = models.CharField( + verbose_name=_('priority'), max_length=50, choices=ContactPriorityChoices, blank=True diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index a41b8bf995d..bcd0a445296 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -2,6 +2,7 @@ from django.db import models from django.db.models import Q from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from netbox.models import NestedGroupModel, PrimaryModel @@ -16,10 +17,12 @@ class TenantGroup(NestedGroupModel): An arbitrary collection of Tenants. """ name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100, unique=True ) @@ -37,9 +40,11 @@ class Tenant(PrimaryModel): department. """ name = models.CharField( + verbose_name=_('name'), max_length=100 ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100 ) group = models.ForeignKey( @@ -65,7 +70,7 @@ class Meta: models.UniqueConstraint( fields=('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( fields=('name',), @@ -75,7 +80,7 @@ class Meta: models.UniqueConstraint( fields=('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( fields=('slug',), diff --git a/netbox/users/models.py b/netbox/users/models.py index c9f932cdf83..6cd4ed487db 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -11,7 +11,7 @@ from django.dispatch import receiver from django.urls import reverse 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 ipam.fields import IPNetworkField @@ -39,7 +39,7 @@ class AdminGroup(Group): Proxy contrib.auth.models.Group for the admin UI """ class Meta: - verbose_name = 'Group' + verbose_name = _('Group') proxy = True @@ -48,7 +48,7 @@ class AdminUser(User): Proxy contrib.auth.models.User for the admin UI """ class Meta: - verbose_name = 'User' + verbose_name = _('User') proxy = True @@ -109,7 +109,7 @@ class UserConfig(models.Model): class Meta: ordering = ['user'] - verbose_name = verbose_name_plural = 'User Preferences' + verbose_name = verbose_name_plural = _('User Preferences') def get(self, path, default=None): """ @@ -175,7 +175,9 @@ def set(self, path, value, commit=False): d = d[key] elif key in d: 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: d = d.setdefault(key, {}) @@ -185,7 +187,9 @@ def set(self, path, value, commit=False): if type(value) is dict: d[key].update(value) 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: d[key] = value @@ -245,26 +249,32 @@ class Token(models.Model): related_name='tokens' ) created = models.DateTimeField( + verbose_name=_('created'), auto_now_add=True ) expires = models.DateTimeField( + verbose_name=_('expires'), blank=True, null=True ) last_used = models.DateTimeField( + verbose_name=_('last used'), blank=True, null=True ) key = models.CharField( + verbose_name=_('key'), max_length=40, unique=True, validators=[MinLengthValidator(40)] ) write_enabled = models.BooleanField( + verbose_name=_('write enabled'), default=True, help_text=_('Permit create/update/delete operations using this key') ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) @@ -272,7 +282,7 @@ class Token(models.Model): base_field=IPNetworkField(), blank=True, null=True, - verbose_name='Allowed IPs', + verbose_name=_('allowed IPs'), help_text=_( '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"' @@ -331,13 +341,16 @@ class ObjectPermission(models.Model): identified by ORM query parameters. """ name = models.CharField( + verbose_name=_('name'), max_length=100 ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) enabled = models.BooleanField( + verbose_name=_('enabled'), default=True ) object_types = models.ManyToManyField( @@ -362,6 +375,7 @@ class ObjectPermission(models.Model): constraints = models.JSONField( blank=True, null=True, + verbose_name=_('constraints'), help_text=_("Queryset filter matching the applicable objects of the selected type(s)") ) @@ -369,7 +383,7 @@ class ObjectPermission(models.Model): class Meta: ordering = ['name'] - verbose_name = "permission" + verbose_name = _("permission") def __str__(self): return self.name diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index 517b92ef2ca..0f48c50f032 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from dcim.models import Device 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. """ name = models.CharField( + verbose_name=_('name'), max_length=100 ) type = models.ForeignKey( + verbose_name=_('type'), to=ClusterType, on_delete=models.PROTECT, related_name='clusters' @@ -61,6 +64,7 @@ class Cluster(PrimaryModel): null=True ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=ClusterStatusChoices, default=ClusterStatusChoices.STATUS_ACTIVE @@ -128,7 +132,7 @@ def clean(self): nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() if nonsite_devices: 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 ) }) diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index dbbfe49edf8..5620e4564eb 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -5,6 +5,7 @@ from django.db.models import Q from django.db.models.functions import Lower from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from dcim.models import BaseInterface from extras.models import ConfigContextModel @@ -63,6 +64,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): null=True ) name = models.CharField( + verbose_name=_('name'), max_length=64 ) _name = NaturalOrderingField( @@ -74,7 +76,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): max_length=50, choices=VirtualMachineStatusChoices, default=VirtualMachineStatusChoices.STATUS_ACTIVE, - verbose_name='Status' + verbose_name=_('status') ) role = models.ForeignKey( to='dcim.DeviceRole', @@ -90,7 +92,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): related_name='+', blank=True, null=True, - verbose_name='Primary IPv4' + verbose_name=_('primary IPv4') ) primary_ip6 = models.OneToOneField( to='ipam.IPAddress', @@ -98,14 +100,14 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): related_name='+', blank=True, null=True, - verbose_name='Primary IPv6' + verbose_name=_('primary IPv6') ) vcpus = models.DecimalField( max_digits=6, decimal_places=2, blank=True, null=True, - verbose_name='vCPUs', + verbose_name=_('vCPUs'), validators=( MinValueValidator(0.01), ) @@ -113,12 +115,12 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): memory = models.PositiveIntegerField( blank=True, null=True, - verbose_name='Memory (MB)' + verbose_name=_('memory (MB)') ) disk = models.PositiveIntegerField( blank=True, null=True, - verbose_name='Disk (GB)' + verbose_name=_('disk (GB)') ) # Counter fields @@ -152,7 +154,7 @@ class Meta: Lower('name'), 'cluster', name='%(app_label)s_%(class)s_unique_name_cluster', 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 @@ def clean(self): # Must be assigned to a site and/or cluster if not self.site and not self.cluster: 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 if self.cluster and self.site and self.cluster.site != self.site: 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 if self.device and not self.cluster: 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(): 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 @@ -195,7 +201,9 @@ def clean(self): if ip is not None: if ip.address.version != family: 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: pass @@ -203,7 +211,7 @@ def clean(self): pass else: 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): @@ -236,6 +244,7 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): related_name='interfaces' ) name = models.CharField( + verbose_name=_('name'), max_length=64 ) _name = NaturalOrderingField( @@ -245,6 +254,7 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): blank=True ) description = models.CharField( + verbose_name=_('description'), max_length=200, blank=True ) @@ -254,13 +264,13 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): related_name='vminterfaces_as_untagged', null=True, blank=True, - verbose_name='Untagged VLAN' + verbose_name=_('untagged VLAN') ) tagged_vlans = models.ManyToManyField( to='ipam.VLAN', related_name='vminterfaces_as_tagged', blank=True, - verbose_name='Tagged VLANs' + verbose_name=_('tagged VLANs') ) ip_addresses = GenericRelation( to='ipam.IPAddress', @@ -274,7 +284,7 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): related_name='vminterfaces', null=True, blank=True, - verbose_name='VRF' + verbose_name=_('VRF') ) fhrp_group_assignments = GenericRelation( to='ipam.FHRPGroupAssignment', @@ -312,26 +322,30 @@ def clean(self): # An interface cannot be its own parent 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 if self.parent and self.parent.virtual_machine != self.virtual_machine: 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 # An interface cannot be bridged to itself 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 if self.bridge and self.bridge.virtual_machine != self.virtual_machine: 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 @@ -339,8 +353,10 @@ def clean(self): # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: 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): diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 1f7d769613f..e49c9938674 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -1,6 +1,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from mptt.models import MPTTModel from dcim.choices import LinkStatusChoices @@ -24,9 +25,10 @@ class WirelessAuthenticationBase(models.Model): max_length=50, choices=WirelessAuthTypeChoices, blank=True, - verbose_name="Auth Type", + verbose_name=_("authentication type"), ) auth_cipher = models.CharField( + verbose_name=_('authentication cipher'), max_length=50, choices=WirelessAuthCipherChoices, blank=True @@ -34,7 +36,7 @@ class WirelessAuthenticationBase(models.Model): auth_psk = models.CharField( max_length=PSK_MAX_LENGTH, blank=True, - verbose_name='Pre-shared key' + verbose_name=_('pre-shared key') ) class Meta: @@ -46,10 +48,12 @@ class WirelessLANGroup(NestedGroupModel): A nested grouping of WirelessLANs """ name = models.CharField( + verbose_name=_('name'), max_length=100, unique=True ) slug = models.SlugField( + verbose_name=_('slug'), max_length=100, unique=True ) @@ -74,7 +78,7 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): """ ssid = models.CharField( max_length=SSID_MAX_LENGTH, - verbose_name='SSID' + verbose_name=_('SSID') ) group = models.ForeignKey( to='wireless.WirelessLANGroup', @@ -86,14 +90,15 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): status = models.CharField( max_length=50, choices=WirelessLANStatusChoices, - default=WirelessLANStatusChoices.STATUS_ACTIVE + default=WirelessLANStatusChoices.STATUS_ACTIVE, + verbose_name=_('status') ) vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.PROTECT, blank=True, null=True, - verbose_name='VLAN' + verbose_name=_('VLAN') ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -134,21 +139,22 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel): limit_choices_to=get_wireless_interface_types, on_delete=models.PROTECT, related_name='+', - verbose_name="Interface A", + verbose_name=_('interface A'), ) interface_b = models.ForeignKey( to='dcim.Interface', limit_choices_to=get_wireless_interface_types, on_delete=models.PROTECT, related_name='+', - verbose_name="Interface B", + verbose_name=_('interface B'), ) ssid = models.CharField( max_length=SSID_MAX_LENGTH, blank=True, - verbose_name='SSID' + verbose_name=_('SSID') ) status = models.CharField( + verbose_name=_('status'), max_length=50, choices=LinkStatusChoices, default=LinkStatusChoices.STATUS_CONNECTED @@ -203,11 +209,15 @@ def clean(self): # Validate interface types if self.interface_a.type not in WIRELESS_IFACE_TYPES: 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: 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):