customfields.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805
  1. import decimal
  2. import json
  3. import re
  4. from datetime import datetime, date
  5. import django_filters
  6. from django import forms
  7. from django.conf import settings
  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_lazy as _
  15. from core.models import ObjectType
  16. from extras.choices import *
  17. from extras.data import CHOICE_SETS
  18. from netbox.models import ChangeLoggedModel
  19. from netbox.models.features import CloningMixin, ExportTemplatesMixin
  20. from netbox.search import FieldTypes
  21. from utilities import filters
  22. from utilities.forms.fields import (
  23. CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicChoiceField,
  24. DynamicModelChoiceField, DynamicModelMultipleChoiceField, DynamicMultipleChoiceField, JSONField, LaxURLField,
  25. )
  26. from utilities.forms.utils import add_blank_choice
  27. from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
  28. from utilities.querysets import RestrictedQuerySet
  29. from utilities.templatetags.builtins.filters import render_markdown
  30. from utilities.validators import validate_regex
  31. __all__ = (
  32. 'CustomField',
  33. 'CustomFieldChoiceSet',
  34. 'CustomFieldManager',
  35. )
  36. SEARCH_TYPES = {
  37. CustomFieldTypeChoices.TYPE_TEXT: FieldTypes.STRING,
  38. CustomFieldTypeChoices.TYPE_LONGTEXT: FieldTypes.STRING,
  39. CustomFieldTypeChoices.TYPE_INTEGER: FieldTypes.INTEGER,
  40. CustomFieldTypeChoices.TYPE_DECIMAL: FieldTypes.FLOAT,
  41. CustomFieldTypeChoices.TYPE_DATE: FieldTypes.STRING,
  42. CustomFieldTypeChoices.TYPE_URL: FieldTypes.STRING,
  43. }
  44. class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
  45. use_in_migrations = True
  46. def get_for_model(self, model):
  47. """
  48. Return all CustomFields assigned to the given model.
  49. """
  50. content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
  51. return self.get_queryset().filter(object_types=content_type)
  52. def get_defaults_for_model(self, model):
  53. """
  54. Return a dictionary of serialized default values for all CustomFields applicable to the given model.
  55. """
  56. custom_fields = self.get_for_model(model).filter(default__isnull=False)
  57. return {
  58. cf.name: cf.default for cf in custom_fields
  59. }
  60. class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
  61. object_types = models.ManyToManyField(
  62. to='core.ObjectType',
  63. related_name='custom_fields',
  64. help_text=_('The object(s) to which this field applies.')
  65. )
  66. type = models.CharField(
  67. verbose_name=_('type'),
  68. max_length=50,
  69. choices=CustomFieldTypeChoices,
  70. default=CustomFieldTypeChoices.TYPE_TEXT,
  71. help_text=_('The type of data this custom field holds')
  72. )
  73. related_object_type = models.ForeignKey(
  74. to='core.ObjectType',
  75. on_delete=models.PROTECT,
  76. blank=True,
  77. null=True,
  78. help_text=_('The type of NetBox object this field maps to (for object fields)')
  79. )
  80. name = models.CharField(
  81. verbose_name=_('name'),
  82. max_length=50,
  83. unique=True,
  84. help_text=_('Internal field name'),
  85. validators=(
  86. RegexValidator(
  87. regex=r'^[a-z0-9_]+$',
  88. message=_("Only alphanumeric characters and underscores are allowed."),
  89. flags=re.IGNORECASE
  90. ),
  91. RegexValidator(
  92. regex=r'__',
  93. message=_("Double underscores are not permitted in custom field names."),
  94. flags=re.IGNORECASE,
  95. inverse_match=True
  96. ),
  97. )
  98. )
  99. label = models.CharField(
  100. verbose_name=_('label'),
  101. max_length=50,
  102. blank=True,
  103. help_text=_(
  104. "Name of the field as displayed to users (if not provided, 'the field's name will be used)"
  105. )
  106. )
  107. group_name = models.CharField(
  108. verbose_name=_('group name'),
  109. max_length=50,
  110. blank=True,
  111. help_text=_("Custom fields within the same group will be displayed together")
  112. )
  113. description = models.CharField(
  114. verbose_name=_('description'),
  115. max_length=200,
  116. blank=True
  117. )
  118. required = models.BooleanField(
  119. verbose_name=_('required'),
  120. default=False,
  121. help_text=_("If true, this field is required when creating new objects or editing an existing object.")
  122. )
  123. search_weight = models.PositiveSmallIntegerField(
  124. verbose_name=_('search weight'),
  125. default=1000,
  126. help_text=_(
  127. "Weighting for search. Lower values are considered more important. Fields with a search weight of zero "
  128. "will be ignored."
  129. )
  130. )
  131. filter_logic = models.CharField(
  132. verbose_name=_('filter logic'),
  133. max_length=50,
  134. choices=CustomFieldFilterLogicChoices,
  135. default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
  136. help_text=_("Loose matches any instance of a given string; exact matches the entire field.")
  137. )
  138. default = models.JSONField(
  139. verbose_name=_('default'),
  140. blank=True,
  141. null=True,
  142. help_text=_(
  143. 'Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. "Foo").'
  144. )
  145. )
  146. weight = models.PositiveSmallIntegerField(
  147. default=100,
  148. verbose_name=_('display weight'),
  149. help_text=_('Fields with higher weights appear lower in a form.')
  150. )
  151. validation_minimum = models.BigIntegerField(
  152. blank=True,
  153. null=True,
  154. verbose_name=_('minimum value'),
  155. help_text=_('Minimum allowed value (for numeric fields)')
  156. )
  157. validation_maximum = models.BigIntegerField(
  158. blank=True,
  159. null=True,
  160. verbose_name=_('maximum value'),
  161. help_text=_('Maximum allowed value (for numeric fields)')
  162. )
  163. validation_regex = models.CharField(
  164. blank=True,
  165. validators=[validate_regex],
  166. max_length=500,
  167. verbose_name=_('validation regex'),
  168. help_text=_(
  169. 'Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. For '
  170. 'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
  171. )
  172. )
  173. validation_unique = models.BooleanField(
  174. verbose_name=_('must be unique'),
  175. default=False,
  176. help_text=_('The value of this field must be unique for the assigned object')
  177. )
  178. choice_set = models.ForeignKey(
  179. to='CustomFieldChoiceSet',
  180. on_delete=models.PROTECT,
  181. related_name='choices_for',
  182. verbose_name=_('choice set'),
  183. blank=True,
  184. null=True
  185. )
  186. ui_visible = models.CharField(
  187. max_length=50,
  188. choices=CustomFieldUIVisibleChoices,
  189. default=CustomFieldUIVisibleChoices.ALWAYS,
  190. verbose_name=_('UI visible'),
  191. help_text=_('Specifies whether the custom field is displayed in the UI')
  192. )
  193. ui_editable = models.CharField(
  194. max_length=50,
  195. choices=CustomFieldUIEditableChoices,
  196. default=CustomFieldUIEditableChoices.YES,
  197. verbose_name=_('UI editable'),
  198. help_text=_('Specifies whether the custom field value can be edited in the UI')
  199. )
  200. is_cloneable = models.BooleanField(
  201. default=False,
  202. verbose_name=_('is cloneable'),
  203. help_text=_('Replicate this value when cloning objects')
  204. )
  205. comments = models.TextField(
  206. verbose_name=_('comments'),
  207. blank=True
  208. )
  209. objects = CustomFieldManager()
  210. clone_fields = (
  211. 'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight',
  212. 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
  213. 'validation_unique', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
  214. )
  215. class Meta:
  216. ordering = ['group_name', 'weight', 'name']
  217. verbose_name = _('custom field')
  218. verbose_name_plural = _('custom fields')
  219. def __str__(self):
  220. return self.label or self.name.replace('_', ' ').capitalize()
  221. def get_absolute_url(self):
  222. return reverse('extras:customfield', args=[self.pk])
  223. @property
  224. def docs_url(self):
  225. return f'{settings.STATIC_URL}docs/models/extras/customfield/'
  226. def __init__(self, *args, **kwargs):
  227. super().__init__(*args, **kwargs)
  228. # Cache instance's original name so we can check later whether it has changed
  229. self._name = self.__dict__.get('name')
  230. @property
  231. def search_type(self):
  232. return SEARCH_TYPES.get(self.type)
  233. @property
  234. def choices(self):
  235. if self.choice_set:
  236. return self.choice_set.choices
  237. return []
  238. def get_ui_visible_color(self):
  239. return CustomFieldUIVisibleChoices.colors.get(self.ui_visible)
  240. def get_ui_editable_color(self):
  241. return CustomFieldUIEditableChoices.colors.get(self.ui_editable)
  242. def get_choice_label(self, value):
  243. if not hasattr(self, '_choice_map'):
  244. self._choice_map = dict(self.choices)
  245. return self._choice_map.get(value, value)
  246. def populate_initial_data(self, content_types):
  247. """
  248. Populate initial custom field data upon either a) the creation of a new CustomField, or
  249. b) the assignment of an existing CustomField to new object types.
  250. """
  251. for ct in content_types:
  252. model = ct.model_class()
  253. instances = model.objects.exclude(**{f'custom_field_data__contains': self.name})
  254. for instance in instances:
  255. instance.custom_field_data[self.name] = self.default
  256. model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
  257. def remove_stale_data(self, content_types):
  258. """
  259. Delete custom field data which is no longer relevant (either because the CustomField is
  260. no longer assigned to a model, or because it has been deleted).
  261. """
  262. for ct in content_types:
  263. model = ct.model_class()
  264. instances = model.objects.filter(custom_field_data__has_key=self.name)
  265. for instance in instances:
  266. del instance.custom_field_data[self.name]
  267. model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
  268. def rename_object_data(self, old_name, new_name):
  269. """
  270. Called when a CustomField has been renamed. Updates all assigned object data.
  271. """
  272. for ct in self.object_types.all():
  273. model = ct.model_class()
  274. params = {f'custom_field_data__{old_name}__isnull': False}
  275. instances = model.objects.filter(**params)
  276. for instance in instances:
  277. instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
  278. model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
  279. def clean(self):
  280. super().clean()
  281. # Validate the field's default value (if any)
  282. if self.default is not None:
  283. try:
  284. if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
  285. default_value = str(self.default)
  286. else:
  287. default_value = self.default
  288. self.validate(default_value)
  289. except ValidationError as err:
  290. raise ValidationError({
  291. 'default': _(
  292. 'Invalid default value "{value}": {error}'
  293. ).format(value=self.default, error=err.message)
  294. })
  295. # Minimum/maximum values can be set only for numeric fields
  296. if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
  297. if self.validation_minimum:
  298. raise ValidationError({'validation_minimum': _("A minimum value may be set only for numeric fields")})
  299. if self.validation_maximum:
  300. raise ValidationError({'validation_maximum': _("A maximum value may be set only for numeric fields")})
  301. # Regex validation can be set only for text fields
  302. regex_types = (
  303. CustomFieldTypeChoices.TYPE_TEXT,
  304. CustomFieldTypeChoices.TYPE_LONGTEXT,
  305. CustomFieldTypeChoices.TYPE_URL,
  306. )
  307. if self.validation_regex and self.type not in regex_types:
  308. raise ValidationError({
  309. 'validation_regex': _("Regular expression validation is supported only for text and URL fields")
  310. })
  311. # Uniqueness can not be enforced for boolean fields
  312. if self.validation_unique and self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  313. raise ValidationError({
  314. 'validation_unique': _("Uniqueness cannot be enforced for boolean fields")
  315. })
  316. # Choice set must be set on selection fields, and *only* on selection fields
  317. if self.type in (
  318. CustomFieldTypeChoices.TYPE_SELECT,
  319. CustomFieldTypeChoices.TYPE_MULTISELECT
  320. ):
  321. if not self.choice_set:
  322. raise ValidationError({
  323. 'choice_set': _("Selection fields must specify a set of choices.")
  324. })
  325. elif self.choice_set:
  326. raise ValidationError({
  327. 'choice_set': _("Choices may be set only on selection fields.")
  328. })
  329. # Object fields must define an object_type; other fields must not
  330. if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
  331. if not self.related_object_type:
  332. raise ValidationError({
  333. 'object_type': _("Object fields must define an object type.")
  334. })
  335. elif self.related_object_type:
  336. raise ValidationError({
  337. 'object_type': _(
  338. "{type} fields may not define an object type.")
  339. .format(type=self.get_type_display())
  340. })
  341. def serialize(self, value):
  342. """
  343. Prepare a value for storage as JSON data.
  344. """
  345. if value is None:
  346. return value
  347. if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date:
  348. return value.isoformat()
  349. if self.type == CustomFieldTypeChoices.TYPE_DATETIME and type(value) is datetime:
  350. return value.isoformat()
  351. if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  352. return value.pk
  353. if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  354. return [obj.pk for obj in value] or None
  355. return value
  356. def deserialize(self, value):
  357. """
  358. Convert JSON data to a Python object suitable for the field type.
  359. """
  360. if value is None:
  361. return value
  362. if self.type == CustomFieldTypeChoices.TYPE_DATE:
  363. try:
  364. return date.fromisoformat(value)
  365. except ValueError:
  366. return value
  367. if self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  368. try:
  369. return datetime.fromisoformat(value)
  370. except ValueError:
  371. return value
  372. if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  373. model = self.related_object_type.model_class()
  374. return model.objects.filter(pk=value).first()
  375. if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  376. model = self.related_object_type.model_class()
  377. return model.objects.filter(pk__in=value)
  378. return value
  379. def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
  380. """
  381. Return a form field suitable for setting a CustomField's value for an object.
  382. set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
  383. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
  384. enforce_visibility: Honor the value of CustomField.ui_visible. Set to False for filtering.
  385. for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
  386. """
  387. initial = self.default if set_initial else None
  388. required = self.required if enforce_required else False
  389. # Integer
  390. if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  391. field = forms.IntegerField(
  392. required=required,
  393. initial=initial,
  394. min_value=self.validation_minimum,
  395. max_value=self.validation_maximum
  396. )
  397. # Decimal
  398. elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
  399. field = forms.DecimalField(
  400. required=required,
  401. initial=initial,
  402. max_digits=12,
  403. decimal_places=4,
  404. min_value=self.validation_minimum,
  405. max_value=self.validation_maximum
  406. )
  407. # Boolean
  408. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  409. choices = (
  410. (None, '---------'),
  411. (True, _('True')),
  412. (False, _('False')),
  413. )
  414. field = forms.NullBooleanField(
  415. required=required, initial=initial, widget=forms.Select(choices=choices)
  416. )
  417. # Date
  418. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  419. field = forms.DateField(required=required, initial=initial, widget=DatePicker())
  420. # Date & time
  421. elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  422. field = forms.DateTimeField(required=required, initial=initial, widget=DateTimePicker())
  423. # Select
  424. elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
  425. choices = self.choice_set.choices
  426. default_choice = self.default if self.default in self.choices else None
  427. if not required or default_choice is None:
  428. choices = add_blank_choice(choices)
  429. # Set the initial value to the first available choice (if any)
  430. if set_initial and default_choice:
  431. initial = default_choice
  432. if for_csv_import:
  433. if self.type == CustomFieldTypeChoices.TYPE_SELECT:
  434. field_class = CSVChoiceField
  435. else:
  436. field_class = CSVMultipleChoiceField
  437. field = field_class(choices=choices, required=required, initial=initial)
  438. else:
  439. if self.type == CustomFieldTypeChoices.TYPE_SELECT:
  440. field_class = DynamicChoiceField
  441. widget_class = APISelect
  442. else:
  443. field_class = DynamicMultipleChoiceField
  444. widget_class = APISelectMultiple
  445. field = field_class(
  446. choices=choices,
  447. required=required,
  448. initial=initial,
  449. widget=widget_class(api_url=f'/api/extras/custom-field-choice-sets/{self.choice_set.pk}/choices/')
  450. )
  451. # URL
  452. elif self.type == CustomFieldTypeChoices.TYPE_URL:
  453. field = LaxURLField(required=required, initial=initial)
  454. # JSON
  455. elif self.type == CustomFieldTypeChoices.TYPE_JSON:
  456. field = JSONField(required=required, initial=json.dumps(initial) if initial else None)
  457. # Object
  458. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  459. model = self.related_object_type.model_class()
  460. field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField
  461. field = field_class(
  462. queryset=model.objects.all(),
  463. required=required,
  464. initial=initial
  465. )
  466. # Multiple objects
  467. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  468. model = self.related_object_type.model_class()
  469. field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
  470. field = field_class(
  471. queryset=model.objects.all(),
  472. required=required,
  473. initial=initial,
  474. )
  475. # Text
  476. else:
  477. widget = forms.Textarea if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT else None
  478. field = forms.CharField(required=required, initial=initial, widget=widget)
  479. if self.validation_regex:
  480. field.validators = [
  481. RegexValidator(
  482. regex=self.validation_regex,
  483. message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
  484. regex=escape(self.validation_regex)
  485. ))
  486. )
  487. ]
  488. field.model = self
  489. field.label = str(self)
  490. if self.description:
  491. field.help_text = render_markdown(self.description)
  492. # Annotate read-only fields
  493. if enforce_visibility and self.ui_editable != CustomFieldUIEditableChoices.YES:
  494. field.disabled = True
  495. return field
  496. def to_filter(self, lookup_expr=None):
  497. """
  498. Return a django_filters Filter instance suitable for this field type.
  499. :param lookup_expr: Custom lookup expression (optional)
  500. """
  501. kwargs = {
  502. 'field_name': f'custom_field_data__{self.name}'
  503. }
  504. if lookup_expr is not None:
  505. kwargs['lookup_expr'] = lookup_expr
  506. # Text/URL
  507. if self.type in (
  508. CustomFieldTypeChoices.TYPE_TEXT,
  509. CustomFieldTypeChoices.TYPE_LONGTEXT,
  510. CustomFieldTypeChoices.TYPE_URL,
  511. ):
  512. filter_class = filters.MultiValueCharFilter
  513. if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
  514. kwargs['lookup_expr'] = 'icontains'
  515. # Integer
  516. elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  517. filter_class = filters.MultiValueNumberFilter
  518. # Decimal
  519. elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
  520. filter_class = filters.MultiValueDecimalFilter
  521. # Boolean
  522. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  523. filter_class = django_filters.BooleanFilter
  524. # Date
  525. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  526. filter_class = filters.MultiValueDateFilter
  527. # Date & time
  528. elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  529. filter_class = filters.MultiValueDateTimeFilter
  530. # Select
  531. elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
  532. filter_class = filters.MultiValueCharFilter
  533. # Multiselect
  534. elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  535. filter_class = filters.MultiValueArrayFilter
  536. # Object
  537. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  538. filter_class = filters.MultiValueNumberFilter
  539. # Multi-object
  540. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  541. filter_class = filters.MultiValueNumberFilter
  542. kwargs['lookup_expr'] = 'contains'
  543. # Unsupported custom field type
  544. else:
  545. return None
  546. filter_instance = filter_class(**kwargs)
  547. filter_instance.custom_field = self
  548. return filter_instance
  549. def validate(self, value):
  550. """
  551. Validate a value according to the field's type validation rules.
  552. """
  553. if value not in [None, '']:
  554. # Validate text field
  555. if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
  556. if type(value) is not str:
  557. raise ValidationError(_("Value must be a string."))
  558. if self.validation_regex and not re.match(self.validation_regex, value):
  559. raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
  560. # Validate integer
  561. elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  562. if type(value) is not int:
  563. raise ValidationError(_("Value must be an integer."))
  564. if self.validation_minimum is not None and value < self.validation_minimum:
  565. raise ValidationError(
  566. _("Value must be at least {minimum}").format(minimum=self.validation_maximum)
  567. )
  568. if self.validation_maximum is not None and value > self.validation_maximum:
  569. raise ValidationError(
  570. _("Value must not exceed {maximum}").format(maximum=self.validation_maximum)
  571. )
  572. # Validate decimal
  573. elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
  574. try:
  575. decimal.Decimal(value)
  576. except decimal.InvalidOperation:
  577. raise ValidationError(_("Value must be a decimal."))
  578. if self.validation_minimum is not None and value < self.validation_minimum:
  579. raise ValidationError(
  580. _("Value must be at least {minimum}").format(minimum=self.validation_minimum)
  581. )
  582. if self.validation_maximum is not None and value > self.validation_maximum:
  583. raise ValidationError(
  584. _("Value must not exceed {maximum}").format(maximum=self.validation_maximum)
  585. )
  586. # Validate boolean
  587. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
  588. raise ValidationError(_("Value must be true or false."))
  589. # Validate date
  590. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  591. if type(value) is not date:
  592. try:
  593. date.fromisoformat(value)
  594. except ValueError:
  595. raise ValidationError(_("Date values must be in ISO 8601 format (YYYY-MM-DD)."))
  596. # Validate date & time
  597. elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  598. if type(value) is not datetime:
  599. # Work around UTC issue for Python < 3.11; see
  600. # https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
  601. if type(value) is str and value.endswith('Z'):
  602. value = f'{value[:-1]}+00:00'
  603. try:
  604. datetime.fromisoformat(value)
  605. except ValueError:
  606. raise ValidationError(
  607. _("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).")
  608. )
  609. # Validate selected choice
  610. elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
  611. if value not in self.choice_set.values:
  612. raise ValidationError(
  613. _("Invalid choice ({value}) for choice set {choiceset}.").format(
  614. value=value,
  615. choiceset=self.choice_set
  616. )
  617. )
  618. # Validate all selected choices
  619. elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  620. if not set(value).issubset(self.choice_set.values):
  621. raise ValidationError(
  622. _("Invalid choice(s) ({value}) for choice set {choiceset}.").format(
  623. value=value,
  624. choiceset=self.choice_set
  625. )
  626. )
  627. # Validate selected object
  628. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  629. if type(value) is not int:
  630. raise ValidationError(_("Value must be an object ID, not {type}").format(type=type(value).__name__))
  631. # Validate selected objects
  632. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  633. if type(value) is not list:
  634. raise ValidationError(
  635. _("Value must be a list of object IDs, not {type}").format(type=type(value).__name__)
  636. )
  637. for id in value:
  638. if type(id) is not int:
  639. raise ValidationError(_("Found invalid object ID: {id}").format(id=id))
  640. elif self.required:
  641. raise ValidationError(_("Required field cannot be empty."))
  642. class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
  643. """
  644. Represents a set of choices available for choice and multi-choice custom fields.
  645. """
  646. name = models.CharField(
  647. max_length=100,
  648. unique=True
  649. )
  650. description = models.CharField(
  651. max_length=200,
  652. blank=True
  653. )
  654. base_choices = models.CharField(
  655. max_length=50,
  656. choices=CustomFieldChoiceSetBaseChoices,
  657. blank=True,
  658. help_text=_('Base set of predefined choices (optional)')
  659. )
  660. extra_choices = ArrayField(
  661. ArrayField(
  662. base_field=models.CharField(max_length=100),
  663. size=2
  664. ),
  665. blank=True,
  666. null=True
  667. )
  668. order_alphabetically = models.BooleanField(
  669. default=False,
  670. help_text=_('Choices are automatically ordered alphabetically')
  671. )
  672. clone_fields = ('extra_choices', 'order_alphabetically')
  673. class Meta:
  674. ordering = ('name',)
  675. verbose_name = _('custom field choice set')
  676. verbose_name_plural = _('custom field choice sets')
  677. def __str__(self):
  678. return self.name
  679. def get_absolute_url(self):
  680. return reverse('extras:customfieldchoiceset', args=[self.pk])
  681. @property
  682. def choices(self):
  683. """
  684. Returns a concatenation of the base and extra choices.
  685. """
  686. if not hasattr(self, '_choices'):
  687. self._choices = []
  688. if self.base_choices:
  689. self._choices.extend(CHOICE_SETS.get(self.base_choices))
  690. if self.extra_choices:
  691. self._choices.extend(self.extra_choices)
  692. if self.order_alphabetically:
  693. self._choices = sorted(self._choices, key=lambda x: x[0])
  694. return self._choices
  695. @property
  696. def choices_count(self):
  697. return len(self.choices)
  698. @property
  699. def values(self):
  700. """
  701. Returns an iterator of the valid choice values.
  702. """
  703. return (x[0] for x in self.choices)
  704. def clean(self):
  705. if not self.base_choices and not self.extra_choices:
  706. raise ValidationError(_("Must define base or extra choices."))
  707. def save(self, *args, **kwargs):
  708. # Sort choices if alphabetical ordering is enforced
  709. if self.order_alphabetically:
  710. self.extra_choices = sorted(self.extra_choices, key=lambda x: x[0])
  711. return super().save(*args, **kwargs)