13132 add gettext_lazy to models

This commit is contained in:
Arthur 2023-07-11 15:30:01 +07:00 committed by Jeremy Stretch
parent 1195efc2d1
commit 8b4b331ffd
5 changed files with 151 additions and 58 deletions

View File

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

View File

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

View File

@ -12,7 +12,7 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from extras.choices import * from extras.choices import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
@ -64,6 +64,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,
@ -77,49 +78,56 @@ 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=_('Name of the field as displayed to users (if not provided, '
'the field\'s name will be used)') '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=_('Weighting for search. Lower values are considered more important. '
'Fields with a search weight of zero will be ignored.') '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,
@ -127,6 +135,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'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=_('Default value for the field (must be a JSON value). Encapsulate '
@ -134,35 +143,41 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
) )
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.'
) )
) )
<<<<<<< HEAD
choice_set = models.ForeignKey( choice_set = models.ForeignKey(
to='CustomFieldChoiceSet', to='CustomFieldChoiceSet',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='choices_for', related_name='choices_for',
=======
choices = ArrayField(
verbose_name=_('choices'),
base_field=models.CharField(max_length=100),
>>>>>>> 9a447ed8f (13132 add gettext_lazy to models)
blank=True, blank=True,
null=True null=True
) )
@ -170,12 +185,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=_('Cloneable'),
help_text=_('Replicate this value when cloning objects') help_text=_('Replicate this value when cloning objects')
) )
@ -265,15 +280,15 @@ 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 = (
@ -283,10 +298,23 @@ 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")
}) })
<<<<<<< HEAD
# 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
=======
# Choices can be set only on selection fields
if self.choices and self.type not in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
):
raise ValidationError({
'choices': _("Choices may be set only for custom selection fields.")
})
# Selection fields must have at least one choice defined
>>>>>>> 9a447ed8f (13132 add gettext_lazy to models)
if self.type in ( if self.type in (
CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT CustomFieldTypeChoices.TYPE_MULTISELECT
@ -297,24 +325,28 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
}) })
elif self.choice_set: elif self.choice_set:
raise ValidationError({ raise ValidationError({
<<<<<<< HEAD
'choice_set': "Choices may be set only on selection fields." 'choice_set': "Choices may be set only on selection fields."
=======
'choices': _("Selection fields must specify at least one choice.")
>>>>>>> 9a447ed8f (13132 add gettext_lazy to models)
}) })
# 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):
@ -393,8 +425,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)
@ -463,7 +495,7 @@ 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))
) )
] ]
@ -476,7 +508,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
@ -558,33 +590,33 @@ 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 {validation_minimum}").format(validation_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 {validation_maximum}").format(validation_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 {validation_minimum}").format(validation_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 {validation_maximum}").format(validation_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:
@ -592,7 +624,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:
@ -600,37 +632,38 @@ 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 self.choices: if value not 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(self.choices): if not set(value).issubset(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):
@ -680,3 +713,4 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
self.extra_choices = sorted(self.choices) self.extra_choices = sorted(self.choices)
return super().save(*args, **kwargs) return super().save(*args, **kwargs)

View File

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

View File

@ -12,7 +12,7 @@ from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from rest_framework.utils.encoders import JSONEncoder from rest_framework.utils.encoders import JSONEncoder
from extras.choices import * from extras.choices import *
@ -53,64 +53,74 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
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=_('type 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=_('type 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=_('type 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=_('type 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=_('type 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=_('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.') '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=_('The complete list of official content types is available '
'<a href="https://www.iana.org/assignments/media-types/media-types.xhtml">here</a>.') '<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=_("User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. "
"Headers should be defined in the format <code>Name: Value</code>. Jinja2 template processing is " "Headers should be defined in the format <code>Name: Value</code>. Jinja2 template processing is "
"supported with the same context as the request body (below).") "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=_('Jinja2 template for a custom request body. If blank, a JSON object representing the change will be '
'included. Available context data includes: <code>event</code>, <code>model</code>, ' '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>.') '<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=_("When provided, the request will include a 'X-Hook-Signature' "
@ -119,20 +129,21 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
"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=_('The specific CA certificate file to use for SSL verification. '
'Leave blank to use the system defaults.') 'Leave blank to use the system defaults.')
) )
@ -164,7 +175,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 +187,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 +230,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,9 +324,11 @@ 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
) )
@ -319,15 +339,17 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
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 +376,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 +429,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 +450,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 +488,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 +515,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 +600,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 +626,7 @@ 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 +637,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 +676,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()