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:
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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:
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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')
|
||||||
)
|
)
|
||||||
|
@ -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")
|
||||||
})
|
})
|
||||||
|
@ -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:
|
||||||
|
@ -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"))
|
||||||
|
@ -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):
|
||||||
|
@ -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]),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -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))
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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(
|
||||||
|
@ -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})'
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
@ -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."))
|
||||||
|
@ -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):
|
||||||
|
@ -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)')
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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',),
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
Reference in New Issue
Block a user