customfields.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. import decimal
  2. import re
  3. from datetime import datetime, date
  4. import django_filters
  5. from django import forms
  6. from django.conf import settings
  7. from django.contrib.contenttypes.models import ContentType
  8. from django.contrib.postgres.fields import ArrayField
  9. from django.core.validators import RegexValidator, ValidationError
  10. from django.db import models
  11. from django.urls import reverse
  12. from django.utils.html import escape
  13. from django.utils.safestring import mark_safe
  14. from django.utils.translation import gettext as _
  15. from extras.choices import *
  16. from extras.utils import FeatureQuery
  17. from netbox.models import ChangeLoggedModel
  18. from netbox.models.features import CloningMixin, ExportTemplatesMixin
  19. from netbox.search import FieldTypes
  20. from utilities import filters
  21. from utilities.forms.fields import (
  22. CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicModelChoiceField,
  23. DynamicModelMultipleChoiceField, JSONField, LaxURLField,
  24. )
  25. from utilities.forms.utils import add_blank_choice
  26. from utilities.forms.widgets import DatePicker, DateTimePicker
  27. from utilities.querysets import RestrictedQuerySet
  28. from utilities.validators import validate_regex
  29. __all__ = (
  30. 'CustomField',
  31. 'CustomFieldManager',
  32. )
  33. SEARCH_TYPES = {
  34. CustomFieldTypeChoices.TYPE_TEXT: FieldTypes.STRING,
  35. CustomFieldTypeChoices.TYPE_LONGTEXT: FieldTypes.STRING,
  36. CustomFieldTypeChoices.TYPE_INTEGER: FieldTypes.INTEGER,
  37. CustomFieldTypeChoices.TYPE_DECIMAL: FieldTypes.FLOAT,
  38. CustomFieldTypeChoices.TYPE_DATE: FieldTypes.STRING,
  39. CustomFieldTypeChoices.TYPE_URL: FieldTypes.STRING,
  40. }
  41. class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
  42. use_in_migrations = True
  43. def get_for_model(self, model):
  44. """
  45. Return all CustomFields assigned to the given model.
  46. """
  47. content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
  48. return self.get_queryset().filter(content_types=content_type)
  49. class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
  50. content_types = models.ManyToManyField(
  51. to=ContentType,
  52. related_name='custom_fields',
  53. limit_choices_to=FeatureQuery('custom_fields'),
  54. help_text=_('The object(s) to which this field applies.')
  55. )
  56. type = models.CharField(
  57. max_length=50,
  58. choices=CustomFieldTypeChoices,
  59. default=CustomFieldTypeChoices.TYPE_TEXT,
  60. help_text=_('The type of data this custom field holds')
  61. )
  62. object_type = models.ForeignKey(
  63. to=ContentType,
  64. on_delete=models.PROTECT,
  65. blank=True,
  66. null=True,
  67. help_text=_('The type of NetBox object this field maps to (for object fields)')
  68. )
  69. name = models.CharField(
  70. max_length=50,
  71. unique=True,
  72. help_text=_('Internal field name'),
  73. validators=(
  74. RegexValidator(
  75. regex=r'^[a-z0-9_]+$',
  76. message="Only alphanumeric characters and underscores are allowed.",
  77. flags=re.IGNORECASE
  78. ),
  79. RegexValidator(
  80. regex=r'__',
  81. message="Double underscores are not permitted in custom field names.",
  82. flags=re.IGNORECASE,
  83. inverse_match=True
  84. ),
  85. )
  86. )
  87. label = models.CharField(
  88. max_length=50,
  89. blank=True,
  90. help_text=_('Name of the field as displayed to users (if not provided, '
  91. 'the field\'s name will be used)')
  92. )
  93. group_name = models.CharField(
  94. max_length=50,
  95. blank=True,
  96. help_text=_("Custom fields within the same group will be displayed together")
  97. )
  98. description = models.CharField(
  99. max_length=200,
  100. blank=True
  101. )
  102. required = models.BooleanField(
  103. default=False,
  104. help_text=_('If true, this field is required when creating new objects '
  105. 'or editing an existing object.')
  106. )
  107. search_weight = models.PositiveSmallIntegerField(
  108. default=1000,
  109. help_text=_('Weighting for search. Lower values are considered more important. '
  110. 'Fields with a search weight of zero will be ignored.')
  111. )
  112. filter_logic = models.CharField(
  113. max_length=50,
  114. choices=CustomFieldFilterLogicChoices,
  115. default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
  116. help_text=_('Loose matches any instance of a given string; exact '
  117. 'matches the entire field.')
  118. )
  119. default = models.JSONField(
  120. blank=True,
  121. null=True,
  122. help_text=_('Default value for the field (must be a JSON value). Encapsulate '
  123. 'strings with double quotes (e.g. "Foo").')
  124. )
  125. weight = models.PositiveSmallIntegerField(
  126. default=100,
  127. verbose_name='Display weight',
  128. help_text=_('Fields with higher weights appear lower in a form.')
  129. )
  130. validation_minimum = models.IntegerField(
  131. blank=True,
  132. null=True,
  133. verbose_name='Minimum value',
  134. help_text=_('Minimum allowed value (for numeric fields)')
  135. )
  136. validation_maximum = models.IntegerField(
  137. blank=True,
  138. null=True,
  139. verbose_name='Maximum value',
  140. help_text=_('Maximum allowed value (for numeric fields)')
  141. )
  142. validation_regex = models.CharField(
  143. blank=True,
  144. validators=[validate_regex],
  145. max_length=500,
  146. verbose_name='Validation regex',
  147. help_text=_(
  148. 'Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. For '
  149. 'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
  150. )
  151. )
  152. choices = ArrayField(
  153. base_field=models.CharField(max_length=100),
  154. blank=True,
  155. null=True,
  156. help_text=_('Comma-separated list of available choices (for selection fields)')
  157. )
  158. ui_visibility = models.CharField(
  159. max_length=50,
  160. choices=CustomFieldVisibilityChoices,
  161. default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
  162. verbose_name='UI visibility',
  163. help_text=_('Specifies the visibility of custom field in the UI')
  164. )
  165. is_cloneable = models.BooleanField(
  166. default=False,
  167. verbose_name='Cloneable',
  168. help_text=_('Replicate this value when cloning objects')
  169. )
  170. objects = CustomFieldManager()
  171. clone_fields = (
  172. 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
  173. 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
  174. 'ui_visibility', 'is_cloneable',
  175. )
  176. class Meta:
  177. ordering = ['group_name', 'weight', 'name']
  178. def __str__(self):
  179. return self.label or self.name.replace('_', ' ').capitalize()
  180. def get_absolute_url(self):
  181. return reverse('extras:customfield', args=[self.pk])
  182. @property
  183. def docs_url(self):
  184. return f'{settings.STATIC_URL}docs/models/extras/customfield/'
  185. def __init__(self, *args, **kwargs):
  186. super().__init__(*args, **kwargs)
  187. # Cache instance's original name so we can check later whether it has changed
  188. self._name = self.name
  189. @property
  190. def search_type(self):
  191. return SEARCH_TYPES.get(self.type)
  192. def populate_initial_data(self, content_types):
  193. """
  194. Populate initial custom field data upon either a) the creation of a new CustomField, or
  195. b) the assignment of an existing CustomField to new object types.
  196. """
  197. for ct in content_types:
  198. model = ct.model_class()
  199. instances = model.objects.exclude(**{f'custom_field_data__contains': self.name})
  200. for instance in instances:
  201. instance.custom_field_data[self.name] = self.default
  202. model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
  203. def remove_stale_data(self, content_types):
  204. """
  205. Delete custom field data which is no longer relevant (either because the CustomField is
  206. no longer assigned to a model, or because it has been deleted).
  207. """
  208. for ct in content_types:
  209. model = ct.model_class()
  210. instances = model.objects.filter(custom_field_data__has_key=self.name)
  211. for instance in instances:
  212. del instance.custom_field_data[self.name]
  213. model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
  214. def rename_object_data(self, old_name, new_name):
  215. """
  216. Called when a CustomField has been renamed. Updates all assigned object data.
  217. """
  218. for ct in self.content_types.all():
  219. model = ct.model_class()
  220. params = {f'custom_field_data__{old_name}__isnull': False}
  221. instances = model.objects.filter(**params)
  222. for instance in instances:
  223. instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
  224. model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
  225. def clean(self):
  226. super().clean()
  227. # Validate the field's default value (if any)
  228. if self.default is not None:
  229. try:
  230. if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
  231. default_value = str(self.default)
  232. else:
  233. default_value = self.default
  234. self.validate(default_value)
  235. except ValidationError as err:
  236. raise ValidationError({
  237. 'default': f'Invalid default value "{self.default}": {err.message}'
  238. })
  239. # Minimum/maximum values can be set only for numeric fields
  240. if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
  241. if self.validation_minimum:
  242. raise ValidationError({'validation_minimum': "A minimum value may be set only for numeric fields"})
  243. if self.validation_maximum:
  244. raise ValidationError({'validation_maximum': "A maximum value may be set only for numeric fields"})
  245. # Regex validation can be set only for text fields
  246. regex_types = (
  247. CustomFieldTypeChoices.TYPE_TEXT,
  248. CustomFieldTypeChoices.TYPE_LONGTEXT,
  249. CustomFieldTypeChoices.TYPE_URL,
  250. )
  251. if self.validation_regex and self.type not in regex_types:
  252. raise ValidationError({
  253. 'validation_regex': "Regular expression validation is supported only for text and URL fields"
  254. })
  255. # Choices can be set only on selection fields
  256. if self.choices and self.type not in (
  257. CustomFieldTypeChoices.TYPE_SELECT,
  258. CustomFieldTypeChoices.TYPE_MULTISELECT
  259. ):
  260. raise ValidationError({
  261. 'choices': "Choices may be set only for custom selection fields."
  262. })
  263. # Selection fields must have at least one choice defined
  264. if self.type in (
  265. CustomFieldTypeChoices.TYPE_SELECT,
  266. CustomFieldTypeChoices.TYPE_MULTISELECT
  267. ) and not self.choices:
  268. raise ValidationError({
  269. 'choices': "Selection fields must specify at least one choice."
  270. })
  271. # A selection field's default (if any) must be present in its available choices
  272. if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
  273. raise ValidationError({
  274. 'default': f"The specified default value ({self.default}) is not listed as an available choice."
  275. })
  276. # Object fields must define an object_type; other fields must not
  277. if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
  278. if not self.object_type:
  279. raise ValidationError({
  280. 'object_type': "Object fields must define an object type."
  281. })
  282. elif self.object_type:
  283. raise ValidationError({
  284. 'object_type': f"{self.get_type_display()} fields may not define an object type."
  285. })
  286. def serialize(self, value):
  287. """
  288. Prepare a value for storage as JSON data.
  289. """
  290. if value is None:
  291. return value
  292. if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date:
  293. return value.isoformat()
  294. if self.type == CustomFieldTypeChoices.TYPE_DATETIME and type(value) is datetime:
  295. return value.isoformat()
  296. if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  297. return value.pk
  298. if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  299. return [obj.pk for obj in value] or None
  300. return value
  301. def deserialize(self, value):
  302. """
  303. Convert JSON data to a Python object suitable for the field type.
  304. """
  305. if value is None:
  306. return value
  307. if self.type == CustomFieldTypeChoices.TYPE_DATE:
  308. try:
  309. return date.fromisoformat(value)
  310. except ValueError:
  311. return value
  312. if self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  313. try:
  314. return datetime.fromisoformat(value)
  315. except ValueError:
  316. return value
  317. if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  318. model = self.object_type.model_class()
  319. return model.objects.filter(pk=value).first()
  320. if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  321. model = self.object_type.model_class()
  322. return model.objects.filter(pk__in=value)
  323. return value
  324. def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
  325. """
  326. Return a form field suitable for setting a CustomField's value for an object.
  327. set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
  328. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
  329. enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering.
  330. for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
  331. """
  332. initial = self.default if set_initial else None
  333. required = self.required if enforce_required else False
  334. # Integer
  335. if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  336. field = forms.IntegerField(
  337. required=required,
  338. initial=initial,
  339. min_value=self.validation_minimum,
  340. max_value=self.validation_maximum
  341. )
  342. # Decimal
  343. elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
  344. field = forms.DecimalField(
  345. required=required,
  346. initial=initial,
  347. max_digits=12,
  348. decimal_places=4,
  349. min_value=self.validation_minimum,
  350. max_value=self.validation_maximum
  351. )
  352. # Boolean
  353. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  354. choices = (
  355. (None, '---------'),
  356. (True, 'True'),
  357. (False, 'False'),
  358. )
  359. field = forms.NullBooleanField(
  360. required=required, initial=initial, widget=forms.Select(choices=choices)
  361. )
  362. # Date
  363. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  364. field = forms.DateField(required=required, initial=initial, widget=DatePicker())
  365. # Date & time
  366. elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  367. field = forms.DateTimeField(required=required, initial=initial, widget=DateTimePicker())
  368. # Select
  369. elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
  370. choices = [(c, c) for c in self.choices]
  371. default_choice = self.default if self.default in self.choices else None
  372. if not required or default_choice is None:
  373. choices = add_blank_choice(choices)
  374. # Set the initial value to the first available choice (if any)
  375. if set_initial and default_choice:
  376. initial = default_choice
  377. if self.type == CustomFieldTypeChoices.TYPE_SELECT:
  378. field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
  379. field = field_class(choices=choices, required=required, initial=initial)
  380. else:
  381. field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
  382. field = field_class(choices=choices, required=required, initial=initial)
  383. # URL
  384. elif self.type == CustomFieldTypeChoices.TYPE_URL:
  385. field = LaxURLField(required=required, initial=initial)
  386. # JSON
  387. elif self.type == CustomFieldTypeChoices.TYPE_JSON:
  388. field = JSONField(required=required, initial=initial)
  389. # Object
  390. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  391. model = self.object_type.model_class()
  392. field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField
  393. field = field_class(
  394. queryset=model.objects.all(),
  395. required=required,
  396. initial=initial
  397. )
  398. # Multiple objects
  399. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  400. model = self.object_type.model_class()
  401. field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
  402. field = field_class(
  403. queryset=model.objects.all(),
  404. required=required,
  405. initial=initial,
  406. )
  407. # Text
  408. else:
  409. widget = forms.Textarea if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT else None
  410. field = forms.CharField(required=required, initial=initial, widget=widget)
  411. if self.validation_regex:
  412. field.validators = [
  413. RegexValidator(
  414. regex=self.validation_regex,
  415. message=mark_safe(f"Values must match this regex: <code>{self.validation_regex}</code>")
  416. )
  417. ]
  418. field.model = self
  419. field.label = str(self)
  420. if self.description:
  421. field.help_text = escape(self.description)
  422. # Annotate read-only fields
  423. if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
  424. field.disabled = True
  425. prepend = '<br />' if field.help_text else ''
  426. field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> Field is set to read-only.'
  427. return field
  428. def to_filter(self, lookup_expr=None):
  429. """
  430. Return a django_filters Filter instance suitable for this field type.
  431. :param lookup_expr: Custom lookup expression (optional)
  432. """
  433. kwargs = {
  434. 'field_name': f'custom_field_data__{self.name}'
  435. }
  436. if lookup_expr is not None:
  437. kwargs['lookup_expr'] = lookup_expr
  438. # Text/URL
  439. if self.type in (
  440. CustomFieldTypeChoices.TYPE_TEXT,
  441. CustomFieldTypeChoices.TYPE_LONGTEXT,
  442. CustomFieldTypeChoices.TYPE_URL,
  443. ):
  444. filter_class = filters.MultiValueCharFilter
  445. if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
  446. kwargs['lookup_expr'] = 'icontains'
  447. # Integer
  448. elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  449. filter_class = filters.MultiValueNumberFilter
  450. # Decimal
  451. elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
  452. filter_class = filters.MultiValueDecimalFilter
  453. # Boolean
  454. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  455. filter_class = django_filters.BooleanFilter
  456. # Date
  457. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  458. filter_class = filters.MultiValueDateFilter
  459. # Date & time
  460. elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  461. filter_class = filters.MultiValueDateTimeFilter
  462. # Select
  463. elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
  464. filter_class = filters.MultiValueCharFilter
  465. # Multiselect
  466. elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  467. filter_class = filters.MultiValueCharFilter
  468. kwargs['lookup_expr'] = 'has_key'
  469. # Object
  470. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  471. filter_class = filters.MultiValueNumberFilter
  472. # Multi-object
  473. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  474. filter_class = filters.MultiValueNumberFilter
  475. kwargs['lookup_expr'] = 'contains'
  476. # Unsupported custom field type
  477. else:
  478. return None
  479. filter_instance = filter_class(**kwargs)
  480. filter_instance.custom_field = self
  481. return filter_instance
  482. def validate(self, value):
  483. """
  484. Validate a value according to the field's type validation rules.
  485. """
  486. if value not in [None, '']:
  487. # Validate text field
  488. if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
  489. if type(value) is not str:
  490. raise ValidationError(f"Value must be a string.")
  491. if self.validation_regex and not re.match(self.validation_regex, value):
  492. raise ValidationError(f"Value must match regex '{self.validation_regex}'")
  493. # Validate integer
  494. elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  495. if type(value) is not int:
  496. raise ValidationError("Value must be an integer.")
  497. if self.validation_minimum is not None and value < self.validation_minimum:
  498. raise ValidationError(f"Value must be at least {self.validation_minimum}")
  499. if self.validation_maximum is not None and value > self.validation_maximum:
  500. raise ValidationError(f"Value must not exceed {self.validation_maximum}")
  501. # Validate decimal
  502. elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
  503. try:
  504. decimal.Decimal(value)
  505. except decimal.InvalidOperation:
  506. raise ValidationError("Value must be a decimal.")
  507. if self.validation_minimum is not None and value < self.validation_minimum:
  508. raise ValidationError(f"Value must be at least {self.validation_minimum}")
  509. if self.validation_maximum is not None and value > self.validation_maximum:
  510. raise ValidationError(f"Value must not exceed {self.validation_maximum}")
  511. # Validate boolean
  512. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
  513. raise ValidationError("Value must be true or false.")
  514. # Validate date
  515. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  516. if type(value) is not date:
  517. try:
  518. date.fromisoformat(value)
  519. except ValueError:
  520. raise ValidationError("Date values must be in ISO 8601 format (YYYY-MM-DD).")
  521. # Validate date & time
  522. elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  523. if type(value) is not datetime:
  524. try:
  525. datetime.fromisoformat(value)
  526. except ValueError:
  527. raise ValidationError("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).")
  528. # Validate selected choice
  529. elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
  530. if value not in self.choices:
  531. raise ValidationError(
  532. f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
  533. )
  534. # Validate all selected choices
  535. elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  536. if not set(value).issubset(self.choices):
  537. raise ValidationError(
  538. f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
  539. )
  540. # Validate selected object
  541. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  542. if type(value) is not int:
  543. raise ValidationError(f"Value must be an object ID, not {type(value).__name__}")
  544. # Validate selected objects
  545. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  546. if type(value) is not list:
  547. raise ValidationError(f"Value must be a list of object IDs, not {type(value).__name__}")
  548. for id in value:
  549. if type(id) is not int:
  550. raise ValidationError(f"Found invalid object ID: {id}")
  551. elif self.required:
  552. raise ValidationError("Required field cannot be empty.")