| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805 |
- import decimal
- import json
- import re
- from datetime import datetime, date
- import django_filters
- from django import forms
- from django.conf import settings
- from django.contrib.postgres.fields import ArrayField
- from django.core.validators import RegexValidator, ValidationError
- from django.db import models
- from django.urls import reverse
- from django.utils.html import escape
- from django.utils.safestring import mark_safe
- from django.utils.translation import gettext_lazy as _
- from core.models import ObjectType
- from extras.choices import *
- from extras.data import CHOICE_SETS
- from netbox.models import ChangeLoggedModel
- from netbox.models.features import CloningMixin, ExportTemplatesMixin
- from netbox.search import FieldTypes
- from utilities import filters
- from utilities.forms.fields import (
- CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicChoiceField,
- DynamicModelChoiceField, DynamicModelMultipleChoiceField, DynamicMultipleChoiceField, JSONField, LaxURLField,
- )
- from utilities.forms.utils import add_blank_choice
- from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
- from utilities.querysets import RestrictedQuerySet
- from utilities.templatetags.builtins.filters import render_markdown
- from utilities.validators import validate_regex
- __all__ = (
- 'CustomField',
- 'CustomFieldChoiceSet',
- 'CustomFieldManager',
- )
- SEARCH_TYPES = {
- CustomFieldTypeChoices.TYPE_TEXT: FieldTypes.STRING,
- CustomFieldTypeChoices.TYPE_LONGTEXT: FieldTypes.STRING,
- CustomFieldTypeChoices.TYPE_INTEGER: FieldTypes.INTEGER,
- CustomFieldTypeChoices.TYPE_DECIMAL: FieldTypes.FLOAT,
- CustomFieldTypeChoices.TYPE_DATE: FieldTypes.STRING,
- CustomFieldTypeChoices.TYPE_URL: FieldTypes.STRING,
- }
- class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
- use_in_migrations = True
- def get_for_model(self, model):
- """
- Return all CustomFields assigned to the given model.
- """
- content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
- return self.get_queryset().filter(object_types=content_type)
- def get_defaults_for_model(self, model):
- """
- Return a dictionary of serialized default values for all CustomFields applicable to the given model.
- """
- custom_fields = self.get_for_model(model).filter(default__isnull=False)
- return {
- cf.name: cf.default for cf in custom_fields
- }
- class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
- object_types = models.ManyToManyField(
- to='core.ObjectType',
- related_name='custom_fields',
- help_text=_('The object(s) to which this field applies.')
- )
- type = models.CharField(
- verbose_name=_('type'),
- max_length=50,
- choices=CustomFieldTypeChoices,
- default=CustomFieldTypeChoices.TYPE_TEXT,
- help_text=_('The type of data this custom field holds')
- )
- related_object_type = models.ForeignKey(
- to='core.ObjectType',
- on_delete=models.PROTECT,
- blank=True,
- null=True,
- help_text=_('The type of NetBox object this field maps to (for object fields)')
- )
- name = models.CharField(
- verbose_name=_('name'),
- max_length=50,
- unique=True,
- help_text=_('Internal field name'),
- validators=(
- RegexValidator(
- regex=r'^[a-z0-9_]+$',
- message=_("Only alphanumeric characters and underscores are allowed."),
- flags=re.IGNORECASE
- ),
- RegexValidator(
- regex=r'__',
- message=_("Double underscores are not permitted in custom field names."),
- flags=re.IGNORECASE,
- inverse_match=True
- ),
- )
- )
- label = models.CharField(
- verbose_name=_('label'),
- max_length=50,
- blank=True,
- help_text=_(
- "Name of the field as displayed to users (if not provided, 'the field's name will be used)"
- )
- )
- group_name = models.CharField(
- verbose_name=_('group name'),
- max_length=50,
- blank=True,
- help_text=_("Custom fields within the same group will be displayed together")
- )
- description = models.CharField(
- verbose_name=_('description'),
- max_length=200,
- blank=True
- )
- required = models.BooleanField(
- verbose_name=_('required'),
- default=False,
- help_text=_("If true, this field is required when creating new objects or editing an existing object.")
- )
- search_weight = models.PositiveSmallIntegerField(
- verbose_name=_('search weight'),
- default=1000,
- help_text=_(
- "Weighting for search. Lower values are considered more important. Fields with a search weight of zero "
- "will be ignored."
- )
- )
- filter_logic = models.CharField(
- verbose_name=_('filter logic'),
- max_length=50,
- choices=CustomFieldFilterLogicChoices,
- default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
- help_text=_("Loose matches any instance of a given string; exact matches the entire field.")
- )
- default = models.JSONField(
- verbose_name=_('default'),
- blank=True,
- null=True,
- help_text=_(
- 'Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. "Foo").'
- )
- )
- weight = models.PositiveSmallIntegerField(
- default=100,
- verbose_name=_('display weight'),
- help_text=_('Fields with higher weights appear lower in a form.')
- )
- validation_minimum = models.BigIntegerField(
- blank=True,
- null=True,
- verbose_name=_('minimum value'),
- help_text=_('Minimum allowed value (for numeric fields)')
- )
- validation_maximum = models.BigIntegerField(
- blank=True,
- null=True,
- verbose_name=_('maximum value'),
- help_text=_('Maximum allowed value (for numeric fields)')
- )
- validation_regex = models.CharField(
- blank=True,
- validators=[validate_regex],
- max_length=500,
- verbose_name=_('validation regex'),
- help_text=_(
- '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.'
- )
- )
- validation_unique = models.BooleanField(
- verbose_name=_('must be unique'),
- default=False,
- help_text=_('The value of this field must be unique for the assigned object')
- )
- choice_set = models.ForeignKey(
- to='CustomFieldChoiceSet',
- on_delete=models.PROTECT,
- related_name='choices_for',
- verbose_name=_('choice set'),
- blank=True,
- null=True
- )
- ui_visible = models.CharField(
- max_length=50,
- choices=CustomFieldUIVisibleChoices,
- default=CustomFieldUIVisibleChoices.ALWAYS,
- verbose_name=_('UI visible'),
- help_text=_('Specifies whether the custom field is displayed in the UI')
- )
- ui_editable = models.CharField(
- max_length=50,
- choices=CustomFieldUIEditableChoices,
- default=CustomFieldUIEditableChoices.YES,
- verbose_name=_('UI editable'),
- help_text=_('Specifies whether the custom field value can be edited in the UI')
- )
- is_cloneable = models.BooleanField(
- default=False,
- verbose_name=_('is cloneable'),
- help_text=_('Replicate this value when cloning objects')
- )
- comments = models.TextField(
- verbose_name=_('comments'),
- blank=True
- )
- objects = CustomFieldManager()
- clone_fields = (
- 'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight',
- 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
- 'validation_unique', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
- )
- class Meta:
- ordering = ['group_name', 'weight', 'name']
- verbose_name = _('custom field')
- verbose_name_plural = _('custom fields')
- def __str__(self):
- return self.label or self.name.replace('_', ' ').capitalize()
- def get_absolute_url(self):
- return reverse('extras:customfield', args=[self.pk])
- @property
- def docs_url(self):
- return f'{settings.STATIC_URL}docs/models/extras/customfield/'
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- # Cache instance's original name so we can check later whether it has changed
- self._name = self.__dict__.get('name')
- @property
- def search_type(self):
- return SEARCH_TYPES.get(self.type)
- @property
- def choices(self):
- if self.choice_set:
- return self.choice_set.choices
- return []
- def get_ui_visible_color(self):
- return CustomFieldUIVisibleChoices.colors.get(self.ui_visible)
- def get_ui_editable_color(self):
- return CustomFieldUIEditableChoices.colors.get(self.ui_editable)
- def get_choice_label(self, value):
- if not hasattr(self, '_choice_map'):
- self._choice_map = dict(self.choices)
- return self._choice_map.get(value, value)
- def populate_initial_data(self, content_types):
- """
- Populate initial custom field data upon either a) the creation of a new CustomField, or
- b) the assignment of an existing CustomField to new object types.
- """
- for ct in content_types:
- model = ct.model_class()
- instances = model.objects.exclude(**{f'custom_field_data__contains': self.name})
- for instance in instances:
- instance.custom_field_data[self.name] = self.default
- model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
- def remove_stale_data(self, content_types):
- """
- Delete custom field data which is no longer relevant (either because the CustomField is
- no longer assigned to a model, or because it has been deleted).
- """
- for ct in content_types:
- model = ct.model_class()
- instances = model.objects.filter(custom_field_data__has_key=self.name)
- for instance in instances:
- del instance.custom_field_data[self.name]
- model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
- def rename_object_data(self, old_name, new_name):
- """
- Called when a CustomField has been renamed. Updates all assigned object data.
- """
- for ct in self.object_types.all():
- model = ct.model_class()
- params = {f'custom_field_data__{old_name}__isnull': False}
- instances = model.objects.filter(**params)
- for instance in instances:
- instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
- model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
- def clean(self):
- super().clean()
- # Validate the field's default value (if any)
- if self.default is not None:
- try:
- if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
- default_value = str(self.default)
- else:
- default_value = self.default
- self.validate(default_value)
- except ValidationError as err:
- raise ValidationError({
- 'default': _(
- 'Invalid default value "{value}": {error}'
- ).format(value=self.default, error=err.message)
- })
- # Minimum/maximum values can be set only for numeric fields
- if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
- if self.validation_minimum:
- raise ValidationError({'validation_minimum': _("A minimum value may be set only for numeric fields")})
- if self.validation_maximum:
- raise ValidationError({'validation_maximum': _("A maximum value may be set only for numeric fields")})
- # Regex validation can be set only for text fields
- regex_types = (
- CustomFieldTypeChoices.TYPE_TEXT,
- CustomFieldTypeChoices.TYPE_LONGTEXT,
- CustomFieldTypeChoices.TYPE_URL,
- )
- if self.validation_regex and self.type not in regex_types:
- raise ValidationError({
- 'validation_regex': _("Regular expression validation is supported only for text and URL fields")
- })
- # Uniqueness can not be enforced for boolean fields
- if self.validation_unique and self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
- raise ValidationError({
- 'validation_unique': _("Uniqueness cannot be enforced for boolean fields")
- })
- # Choice set must be set on selection fields, and *only* on selection fields
- if self.type in (
- CustomFieldTypeChoices.TYPE_SELECT,
- CustomFieldTypeChoices.TYPE_MULTISELECT
- ):
- if not self.choice_set:
- raise ValidationError({
- 'choice_set': _("Selection fields must specify a set of choices.")
- })
- elif self.choice_set:
- raise ValidationError({
- 'choice_set': _("Choices may be set only on selection fields.")
- })
- # Object fields must define an object_type; other fields must not
- if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
- if not self.related_object_type:
- raise ValidationError({
- 'object_type': _("Object fields must define an object type.")
- })
- elif self.related_object_type:
- raise ValidationError({
- 'object_type': _(
- "{type} fields may not define an object type.")
- .format(type=self.get_type_display())
- })
- def serialize(self, value):
- """
- Prepare a value for storage as JSON data.
- """
- if value is None:
- return value
- if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date:
- return value.isoformat()
- if self.type == CustomFieldTypeChoices.TYPE_DATETIME and type(value) is datetime:
- return value.isoformat()
- if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
- return value.pk
- if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
- return [obj.pk for obj in value] or None
- return value
- def deserialize(self, value):
- """
- Convert JSON data to a Python object suitable for the field type.
- """
- if value is None:
- return value
- if self.type == CustomFieldTypeChoices.TYPE_DATE:
- try:
- return date.fromisoformat(value)
- except ValueError:
- return value
- if self.type == CustomFieldTypeChoices.TYPE_DATETIME:
- try:
- return datetime.fromisoformat(value)
- except ValueError:
- return value
- if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
- model = self.related_object_type.model_class()
- return model.objects.filter(pk=value).first()
- if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
- model = self.related_object_type.model_class()
- return model.objects.filter(pk__in=value)
- return value
- def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
- """
- Return a form field suitable for setting a CustomField's value for an object.
- set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
- enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
- enforce_visibility: Honor the value of CustomField.ui_visible. Set to False for filtering.
- for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
- """
- initial = self.default if set_initial else None
- required = self.required if enforce_required else False
- # Integer
- if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
- field = forms.IntegerField(
- required=required,
- initial=initial,
- min_value=self.validation_minimum,
- max_value=self.validation_maximum
- )
- # Decimal
- elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
- field = forms.DecimalField(
- required=required,
- initial=initial,
- max_digits=12,
- decimal_places=4,
- min_value=self.validation_minimum,
- max_value=self.validation_maximum
- )
- # Boolean
- elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
- choices = (
- (None, '---------'),
- (True, _('True')),
- (False, _('False')),
- )
- field = forms.NullBooleanField(
- required=required, initial=initial, widget=forms.Select(choices=choices)
- )
- # Date
- elif self.type == CustomFieldTypeChoices.TYPE_DATE:
- field = forms.DateField(required=required, initial=initial, widget=DatePicker())
- # Date & time
- elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
- field = forms.DateTimeField(required=required, initial=initial, widget=DateTimePicker())
- # Select
- elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
- choices = self.choice_set.choices
- default_choice = self.default if self.default in self.choices else None
- if not required or default_choice is None:
- choices = add_blank_choice(choices)
- # Set the initial value to the first available choice (if any)
- if set_initial and default_choice:
- initial = default_choice
- if for_csv_import:
- if self.type == CustomFieldTypeChoices.TYPE_SELECT:
- field_class = CSVChoiceField
- else:
- field_class = CSVMultipleChoiceField
- field = field_class(choices=choices, required=required, initial=initial)
- else:
- if self.type == CustomFieldTypeChoices.TYPE_SELECT:
- field_class = DynamicChoiceField
- widget_class = APISelect
- else:
- field_class = DynamicMultipleChoiceField
- widget_class = APISelectMultiple
- field = field_class(
- choices=choices,
- required=required,
- initial=initial,
- widget=widget_class(api_url=f'/api/extras/custom-field-choice-sets/{self.choice_set.pk}/choices/')
- )
- # URL
- elif self.type == CustomFieldTypeChoices.TYPE_URL:
- field = LaxURLField(required=required, initial=initial)
- # JSON
- elif self.type == CustomFieldTypeChoices.TYPE_JSON:
- field = JSONField(required=required, initial=json.dumps(initial) if initial else None)
- # Object
- elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
- model = self.related_object_type.model_class()
- field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField
- field = field_class(
- queryset=model.objects.all(),
- required=required,
- initial=initial
- )
- # Multiple objects
- elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
- model = self.related_object_type.model_class()
- field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
- field = field_class(
- queryset=model.objects.all(),
- required=required,
- initial=initial,
- )
- # Text
- else:
- widget = forms.Textarea if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT else None
- field = forms.CharField(required=required, initial=initial, widget=widget)
- if self.validation_regex:
- field.validators = [
- RegexValidator(
- regex=self.validation_regex,
- message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
- regex=escape(self.validation_regex)
- ))
- )
- ]
- field.model = self
- field.label = str(self)
- if self.description:
- field.help_text = render_markdown(self.description)
- # Annotate read-only fields
- if enforce_visibility and self.ui_editable != CustomFieldUIEditableChoices.YES:
- field.disabled = True
- return field
- def to_filter(self, lookup_expr=None):
- """
- Return a django_filters Filter instance suitable for this field type.
- :param lookup_expr: Custom lookup expression (optional)
- """
- kwargs = {
- 'field_name': f'custom_field_data__{self.name}'
- }
- if lookup_expr is not None:
- kwargs['lookup_expr'] = lookup_expr
- # Text/URL
- if self.type in (
- CustomFieldTypeChoices.TYPE_TEXT,
- CustomFieldTypeChoices.TYPE_LONGTEXT,
- CustomFieldTypeChoices.TYPE_URL,
- ):
- filter_class = filters.MultiValueCharFilter
- if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
- kwargs['lookup_expr'] = 'icontains'
- # Integer
- elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
- filter_class = filters.MultiValueNumberFilter
- # Decimal
- elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
- filter_class = filters.MultiValueDecimalFilter
- # Boolean
- elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
- filter_class = django_filters.BooleanFilter
- # Date
- elif self.type == CustomFieldTypeChoices.TYPE_DATE:
- filter_class = filters.MultiValueDateFilter
- # Date & time
- elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
- filter_class = filters.MultiValueDateTimeFilter
- # Select
- elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
- filter_class = filters.MultiValueCharFilter
- # Multiselect
- elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
- filter_class = filters.MultiValueArrayFilter
- # Object
- elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
- filter_class = filters.MultiValueNumberFilter
- # Multi-object
- elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
- filter_class = filters.MultiValueNumberFilter
- kwargs['lookup_expr'] = 'contains'
- # Unsupported custom field type
- else:
- return None
- filter_instance = filter_class(**kwargs)
- filter_instance.custom_field = self
- return filter_instance
- def validate(self, value):
- """
- Validate a value according to the field's type validation rules.
- """
- if value not in [None, '']:
- # Validate text field
- if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
- if type(value) is not str:
- raise ValidationError(_("Value must be a string."))
- if self.validation_regex and not re.match(self.validation_regex, value):
- raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
- # Validate integer
- elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
- if type(value) is not int:
- raise ValidationError(_("Value must be an integer."))
- if self.validation_minimum is not None and value < self.validation_minimum:
- raise ValidationError(
- _("Value must be at least {minimum}").format(minimum=self.validation_maximum)
- )
- if self.validation_maximum is not None and value > self.validation_maximum:
- raise ValidationError(
- _("Value must not exceed {maximum}").format(maximum=self.validation_maximum)
- )
- # Validate decimal
- elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
- try:
- decimal.Decimal(value)
- except decimal.InvalidOperation:
- raise ValidationError(_("Value must be a decimal."))
- if self.validation_minimum is not None and value < self.validation_minimum:
- raise ValidationError(
- _("Value must be at least {minimum}").format(minimum=self.validation_minimum)
- )
- if self.validation_maximum is not None and value > self.validation_maximum:
- raise ValidationError(
- _("Value must not exceed {maximum}").format(maximum=self.validation_maximum)
- )
- # Validate boolean
- elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
- raise ValidationError(_("Value must be true or false."))
- # Validate date
- elif self.type == CustomFieldTypeChoices.TYPE_DATE:
- if type(value) is not date:
- try:
- date.fromisoformat(value)
- except ValueError:
- raise ValidationError(_("Date values must be in ISO 8601 format (YYYY-MM-DD)."))
- # Validate date & time
- elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
- if type(value) is not datetime:
- # Work around UTC issue for Python < 3.11; see
- # https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
- if type(value) is str and value.endswith('Z'):
- value = f'{value[:-1]}+00:00'
- try:
- datetime.fromisoformat(value)
- except ValueError:
- raise ValidationError(
- _("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).")
- )
- # Validate selected choice
- elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
- if value not in self.choice_set.values:
- raise ValidationError(
- _("Invalid choice ({value}) for choice set {choiceset}.").format(
- value=value,
- choiceset=self.choice_set
- )
- )
- # Validate all selected choices
- elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
- if not set(value).issubset(self.choice_set.values):
- raise ValidationError(
- _("Invalid choice(s) ({value}) for choice set {choiceset}.").format(
- value=value,
- choiceset=self.choice_set
- )
- )
- # Validate selected object
- elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
- if type(value) is not int:
- raise ValidationError(_("Value must be an object ID, not {type}").format(type=type(value).__name__))
- # Validate selected objects
- elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
- if type(value) is not list:
- raise ValidationError(
- _("Value must be a list of object IDs, not {type}").format(type=type(value).__name__)
- )
- for id in value:
- if type(id) is not int:
- raise ValidationError(_("Found invalid object ID: {id}").format(id=id))
- elif self.required:
- raise ValidationError(_("Required field cannot be empty."))
- class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
- """
- Represents a set of choices available for choice and multi-choice custom fields.
- """
- name = models.CharField(
- max_length=100,
- unique=True
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
- base_choices = models.CharField(
- max_length=50,
- choices=CustomFieldChoiceSetBaseChoices,
- blank=True,
- help_text=_('Base set of predefined choices (optional)')
- )
- extra_choices = ArrayField(
- ArrayField(
- base_field=models.CharField(max_length=100),
- size=2
- ),
- blank=True,
- null=True
- )
- order_alphabetically = models.BooleanField(
- default=False,
- help_text=_('Choices are automatically ordered alphabetically')
- )
- clone_fields = ('extra_choices', 'order_alphabetically')
- class Meta:
- ordering = ('name',)
- verbose_name = _('custom field choice set')
- verbose_name_plural = _('custom field choice sets')
- def __str__(self):
- return self.name
- def get_absolute_url(self):
- return reverse('extras:customfieldchoiceset', args=[self.pk])
- @property
- def choices(self):
- """
- Returns a concatenation of the base and extra choices.
- """
- if not hasattr(self, '_choices'):
- self._choices = []
- if self.base_choices:
- self._choices.extend(CHOICE_SETS.get(self.base_choices))
- if self.extra_choices:
- self._choices.extend(self.extra_choices)
- if self.order_alphabetically:
- self._choices = sorted(self._choices, key=lambda x: x[0])
- return self._choices
- @property
- def choices_count(self):
- return len(self.choices)
- @property
- def values(self):
- """
- Returns an iterator of the valid choice values.
- """
- return (x[0] for x in self.choices)
- def clean(self):
- if not self.base_choices and not self.extra_choices:
- raise ValidationError(_("Must define base or extra choices."))
- def save(self, *args, **kwargs):
- # Sort choices if alphabetical ordering is enforced
- if self.order_alphabetically:
- self.extra_choices = sorted(self.extra_choices, key=lambda x: x[0])
- return super().save(*args, **kwargs)
|