1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

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

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
Arthur Hanson
2023-07-31 22:28:07 +07:00
committed by GitHub
parent 80376abedf
commit 83bebc1bd2
36 changed files with 899 additions and 431 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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