customfields.py 61 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420
  1. import copy
  2. import decimal
  3. import json
  4. import re
  5. from datetime import date, datetime
  6. import django_filters
  7. import jsonschema
  8. from django import forms
  9. from django.conf import settings
  10. from django.core.validators import RegexValidator, ValidationError
  11. from django.db import connections, models, router, transaction
  12. from django.db.models import F, Func, Q, Value
  13. from django.urls import reverse
  14. from django.utils.html import escape
  15. from django.utils.safestring import mark_safe
  16. from django.utils.translation import gettext_lazy as _
  17. from jsonschema.exceptions import ValidationError as JSONValidationError
  18. from core.models import ObjectType
  19. from extras.choices import *
  20. from extras.data import CHOICE_SETS
  21. from extras.fields import ChoiceSetField
  22. from netbox.constants import ADVISORY_LOCK_KEYS
  23. from netbox.context import query_cache
  24. from netbox.models import ChangeLoggedModel
  25. from netbox.models.features import CloningMixin, ExportTemplatesMixin
  26. from netbox.models.mixins import OwnerMixin
  27. from netbox.search import FieldTypes
  28. from utilities import filters
  29. from utilities.datetime import datetime_from_timestamp
  30. from utilities.exceptions import AbortRequest
  31. from utilities.forms.fields import (
  32. CSVChoiceField,
  33. CSVModelChoiceField,
  34. CSVModelMultipleChoiceField,
  35. CSVMultipleChoiceField,
  36. DynamicChoiceField,
  37. DynamicModelChoiceField,
  38. DynamicModelMultipleChoiceField,
  39. DynamicMultipleChoiceField,
  40. JSONField,
  41. LaxURLField,
  42. )
  43. from utilities.forms.utils import add_blank_choice
  44. from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
  45. from utilities.jsonschema import validate_schema
  46. from utilities.querysets import RestrictedQuerySet, chunked_update
  47. from utilities.templatetags.builtins.filters import render_markdown
  48. from utilities.validators import url_scheme_is_allowed, validate_regex
  49. __all__ = (
  50. 'CustomField',
  51. 'CustomFieldChoiceSet',
  52. 'CustomFieldManager',
  53. )
  54. SEARCH_TYPES = {
  55. CustomFieldTypeChoices.TYPE_TEXT: FieldTypes.STRING,
  56. CustomFieldTypeChoices.TYPE_LONGTEXT: FieldTypes.STRING,
  57. CustomFieldTypeChoices.TYPE_INTEGER: FieldTypes.INTEGER,
  58. CustomFieldTypeChoices.TYPE_DECIMAL: FieldTypes.FLOAT,
  59. CustomFieldTypeChoices.TYPE_DATE: FieldTypes.STRING,
  60. CustomFieldTypeChoices.TYPE_URL: FieldTypes.STRING,
  61. }
  62. class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
  63. use_in_migrations = True
  64. def get_for_model(self, model, statuses=(CustomFieldStatusChoices.STATUS_ACTIVE,)):
  65. """
  66. Return a list of the CustomFields assigned to the given model which hold one of the given
  67. statuses.
  68. Only active fields are returned by default: a field awaiting a bulk update of its stored data
  69. is not live, and must be invisible to every consumer of custom field data until that work
  70. completes (see CustomFieldStatusChoices). This is the sole entry point by which custom fields
  71. are resolved for an object, so excluding them here excludes them everywhere.
  72. Every assigned field is fetched and cached whichever statuses are asked for, so that callers
  73. wanting different subsets share one query per model per request.
  74. Args:
  75. model: The model whose custom fields are to be returned
  76. statuses: The statuses to select (active only by default)
  77. """
  78. cache = query_cache.get()
  79. # Check the request cache before hitting the database. Test the cached value against None
  80. # rather than for truthiness: a model with no custom fields caches an empty list, which
  81. # would otherwise be treated as a miss and re-queried on every call.
  82. custom_fields = cache['custom_fields'].get(model._meta.model) if cache is not None else None
  83. if custom_fields is None:
  84. content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
  85. custom_fields = list(
  86. self.get_queryset().filter(object_types=content_type).select_related(
  87. 'related_object_type', 'choice_set'
  88. )
  89. )
  90. # Populate the request cache to avoid redundant lookups
  91. if cache is not None:
  92. cache['custom_fields'][model._meta.model] = custom_fields
  93. return [cf for cf in custom_fields if cf.status in statuses]
  94. def get_defaults_for_model(self, model):
  95. """
  96. Return a dictionary of serialized default values for all CustomFields applicable to the given model.
  97. Fields still being provisioned are included, unlike in get_for_model(). The provisioning job
  98. backfills only the objects which predate the field, so an object created while it runs must
  99. pick up the default here or never receive one at all.
  100. The defaults are assembled on each call from the fields cached by get_for_model() rather than
  101. cached in their own right: building them costs a pass over a handful of objects already in
  102. memory, where a second cache would have to be kept coherent with the first.
  103. """
  104. custom_fields = self.get_for_model(model, statuses=CustomFieldStatusChoices.DATA_STATUSES)
  105. # Copied so that a mutable default cannot be aliased into the object data of every object
  106. # which takes it, the fields above being cached for the life of the request.
  107. return {
  108. cf.name: copy.deepcopy(cf.default) for cf in custom_fields if cf.default is not None
  109. }
  110. @staticmethod
  111. def clear_cache():
  112. """
  113. Discard the custom fields cached for the current request, so that a subsequent read reflects
  114. a change which has been applied to the database without passing through save().
  115. Called wherever a field's status is written directly (see CustomFieldStatusChoices): the
  116. cache spans the whole of a request -- and the whole of a script or job run -- so a field
  117. taken offline, brought live, or marked for deletion partway through one would otherwise
  118. remain visible, or invisible, to everything which followed it there.
  119. """
  120. if (cache := query_cache.get()) is not None:
  121. cache['custom_fields'].clear()
  122. class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedModel):
  123. object_types = models.ManyToManyField(
  124. to='contenttypes.ContentType',
  125. related_name='custom_fields',
  126. help_text=_('The object(s) to which this field applies.')
  127. )
  128. type = models.CharField(
  129. verbose_name=_('type'),
  130. max_length=50,
  131. choices=CustomFieldTypeChoices,
  132. default=CustomFieldTypeChoices.TYPE_TEXT,
  133. help_text=_('The type of data this custom field holds')
  134. )
  135. related_object_type = models.ForeignKey(
  136. to='contenttypes.ContentType',
  137. on_delete=models.PROTECT,
  138. blank=True,
  139. null=True,
  140. help_text=_('The type of NetBox object this field maps to (for object fields)')
  141. )
  142. name = models.CharField(
  143. verbose_name=_('name'),
  144. max_length=50,
  145. unique=True,
  146. help_text=_('Internal field name'),
  147. validators=(
  148. RegexValidator(
  149. regex=r'^[a-z0-9_]+$',
  150. message=_("Only alphanumeric characters and underscores are allowed."),
  151. flags=re.IGNORECASE
  152. ),
  153. RegexValidator(
  154. regex=r'__',
  155. message=_("Double underscores are not permitted in custom field names."),
  156. flags=re.IGNORECASE,
  157. inverse_match=True
  158. ),
  159. )
  160. )
  161. status = models.CharField(
  162. max_length=50,
  163. choices=CustomFieldStatusChoices,
  164. default=CustomFieldStatusChoices.STATUS_ACTIVE,
  165. verbose_name=_('status'),
  166. help_text=_("Operational state of the field"),
  167. editable=False
  168. )
  169. label = models.CharField(
  170. verbose_name=_('label'),
  171. max_length=50,
  172. blank=True,
  173. help_text=_(
  174. "Name of the field as displayed to users (if not provided, 'the field's name will be used)"
  175. )
  176. )
  177. group_name = models.CharField(
  178. verbose_name=_('group name'),
  179. max_length=50,
  180. blank=True,
  181. help_text=_("Custom fields within the same group will be displayed together")
  182. )
  183. description = models.CharField(
  184. verbose_name=_('description'),
  185. max_length=200,
  186. blank=True
  187. )
  188. required = models.BooleanField(
  189. verbose_name=_('required'),
  190. default=False,
  191. help_text=_("This field is required when creating new objects or editing an existing object.")
  192. )
  193. unique = models.BooleanField(
  194. verbose_name=_('must be unique'),
  195. default=False,
  196. help_text=_("The value of this field must be unique for the assigned object")
  197. )
  198. search_weight = models.PositiveSmallIntegerField(
  199. verbose_name=_('search weight'),
  200. default=1000,
  201. help_text=_(
  202. "Weighting for search. Lower values are considered more important. Fields with a search weight of zero "
  203. "will be ignored."
  204. )
  205. )
  206. filter_logic = models.CharField(
  207. verbose_name=_('filter logic'),
  208. max_length=50,
  209. choices=CustomFieldFilterLogicChoices,
  210. default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
  211. help_text=_("Loose matches any instance of a given string; exact matches the entire field.")
  212. )
  213. default = models.JSONField(
  214. verbose_name=_('default'),
  215. blank=True,
  216. null=True,
  217. help_text=_(
  218. 'Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. "Foo").'
  219. )
  220. )
  221. related_object_filter = models.JSONField(
  222. blank=True,
  223. null=True,
  224. help_text=_(
  225. 'Filter the object selection choices using a query_params dict (must be a JSON value).'
  226. 'Encapsulate strings with double quotes (e.g. "Foo").'
  227. )
  228. )
  229. weight = models.PositiveSmallIntegerField(
  230. default=100,
  231. verbose_name=_('display weight'),
  232. help_text=_('Fields with higher weights appear lower in a form.')
  233. )
  234. validation_minimum = models.DecimalField(
  235. max_digits=16,
  236. decimal_places=4,
  237. blank=True,
  238. null=True,
  239. verbose_name=_('minimum value'),
  240. help_text=_('Minimum allowed value (for numeric fields)')
  241. )
  242. validation_maximum = models.DecimalField(
  243. max_digits=16,
  244. decimal_places=4,
  245. blank=True,
  246. null=True,
  247. verbose_name=_('maximum value'),
  248. help_text=_('Maximum allowed value (for numeric fields)')
  249. )
  250. validation_regex = models.CharField(
  251. blank=True,
  252. validators=[validate_regex],
  253. max_length=500,
  254. verbose_name=_('validation regex'),
  255. help_text=_(
  256. 'Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. For '
  257. 'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
  258. )
  259. )
  260. validation_schema = models.JSONField(
  261. blank=True,
  262. null=True,
  263. validators=[validate_schema],
  264. verbose_name=_('validation schema'),
  265. help_text=_('A JSON schema definition for validating the custom field value')
  266. )
  267. choice_set = models.ForeignKey(
  268. to='CustomFieldChoiceSet',
  269. on_delete=models.PROTECT,
  270. related_name='choices_for',
  271. verbose_name=_('choice set'),
  272. blank=True,
  273. null=True
  274. )
  275. ui_visible = models.CharField(
  276. max_length=50,
  277. choices=CustomFieldUIVisibleChoices,
  278. default=CustomFieldUIVisibleChoices.ALWAYS,
  279. verbose_name=_('UI visible'),
  280. help_text=_('Specifies whether the custom field is displayed in the UI')
  281. )
  282. ui_editable = models.CharField(
  283. max_length=50,
  284. choices=CustomFieldUIEditableChoices,
  285. default=CustomFieldUIEditableChoices.YES,
  286. verbose_name=_('UI editable'),
  287. help_text=_('Specifies whether the custom field value can be edited in the UI')
  288. )
  289. is_cloneable = models.BooleanField(
  290. default=False,
  291. verbose_name=_('is cloneable'),
  292. help_text=_('Replicate this value when cloning objects')
  293. )
  294. nulls_first = models.BooleanField(
  295. default=True,
  296. verbose_name=_('nulls first'),
  297. help_text=_('Sort null values before non-null values when ordering by this field')
  298. )
  299. comments = models.TextField(
  300. verbose_name=_('comments'),
  301. blank=True
  302. )
  303. objects = CustomFieldManager()
  304. clone_fields = (
  305. 'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'unique',
  306. 'search_weight', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
  307. 'validation_regex', 'validation_schema', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
  308. 'nulls_first',
  309. )
  310. class Meta:
  311. ordering = ['group_name', 'weight', 'name']
  312. indexes = (
  313. models.Index(fields=('group_name', 'weight', 'name')), # Default ordering
  314. )
  315. verbose_name = _('custom field')
  316. verbose_name_plural = _('custom fields')
  317. def __str__(self):
  318. return self.label or self.name.replace('_', ' ').capitalize()
  319. def get_absolute_url(self):
  320. return reverse('extras:customfield', args=[self.pk])
  321. @property
  322. def docs_url(self):
  323. return f'{settings.STATIC_URL}docs/models/extras/customfield/'
  324. def __init__(self, *args, **kwargs):
  325. super().__init__(*args, **kwargs)
  326. # Cache instance's original name so we can check later whether it has changed
  327. self._name = self.__dict__.get('name')
  328. @property
  329. def search_type(self):
  330. return SEARCH_TYPES.get(self.type)
  331. @property
  332. def choices(self):
  333. if self.choice_set:
  334. return self.choice_set.choices
  335. return []
  336. def get_status_color(self):
  337. return CustomFieldStatusChoices.colors.get(self.status)
  338. def get_ui_visible_color(self):
  339. return CustomFieldUIVisibleChoices.colors.get(self.ui_visible)
  340. def get_ui_editable_color(self):
  341. return CustomFieldUIEditableChoices.colors.get(self.ui_editable)
  342. def get_choice_label(self, value):
  343. if not hasattr(self, '_choice_map'):
  344. self._choice_map = dict(self.choices)
  345. return self._choice_map.get(value, value)
  346. def get_choice_color(self, value):
  347. if self.choice_set:
  348. return self.choice_set.get_choice_color(value)
  349. return None
  350. def resolve_selection_value(self, value):
  351. """
  352. For a Selection or Multiple selection field, wrap the value(s) with their resolved label as
  353. {'value': ..., 'label': ...} (a list thereof for multi-select). Other field types pass through
  354. unchanged. Shared by the REST API and GraphQL so selection labels resolve consistently (#20897).
  355. """
  356. if value is None:
  357. return value
  358. if self.type == CustomFieldTypeChoices.TYPE_SELECT:
  359. return {'value': value, 'label': self.get_choice_label(value)}
  360. if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  361. return [{'value': v, 'label': self.get_choice_label(v)} for v in value]
  362. return value
  363. @staticmethod
  364. def data_lock_key(pk):
  365. """
  366. The advisory lock which serializes bulk updates of a field's stored data against one another
  367. and against its deletion, keyed by primary key so that work on one field never waits on
  368. another.
  369. """
  370. return ADVISORY_LOCK_KEYS['custom-field-data'], pk
  371. @classmethod
  372. def _try_lock_data(cls, pk, using):
  373. """
  374. Take the field's data lock at transaction scope, returning False if it is held elsewhere.
  375. Never waits: a job holds this lock for the duration of its bulk update, which may run for
  376. hours (see CUSTOMFIELD_JOB_TIMEOUT).
  377. """
  378. with connections[using].cursor() as cursor:
  379. cursor.execute('SELECT pg_try_advisory_xact_lock(%s, %s)', cls.data_lock_key(pk))
  380. return cursor.fetchone()[0]
  381. def _lock_status(self, using):
  382. """
  383. Re-read the field's status under a row lock, returning None where the row no longer exists.
  384. The status is not taken from this instance, which a job or a concurrent request may have
  385. changed since it was fetched, and which must not change between being checked by the caller
  386. and the field being marked below.
  387. """
  388. return self.__class__.objects.using(using).select_for_update().filter(
  389. pk=self.pk
  390. ).values_list('status', flat=True).first()
  391. @staticmethod
  392. def _update_object_data(model, filters=None, commit_per_batch=False, **update_kwargs):
  393. """
  394. Apply an UPDATE to the custom_field_data of every instance of the given model, in batches
  395. of at most BULK_UPDATE_CHUNK_SIZE rows. Bounding the number of rows touched by each statement
  396. keeps a very large table from exceeding the database statement timeout, as a JSONB update
  397. rewrites each affected row in full.
  398. :param filters: Optional Q object restricting which rows are updated. Negate it to address
  399. the rows which do not match instead.
  400. :param commit_per_batch: Commit each batch independently rather than wrapping them all in a
  401. single transaction, so that a long-running job does not hold row locks for its whole
  402. duration. Only for updates which can safely be resumed.
  403. """
  404. return chunked_update(
  405. model.objects.filter(filters or Q()),
  406. commit_per_batch=commit_per_batch,
  407. **update_kwargs,
  408. )
  409. @staticmethod
  410. def _exceeds_inline_limit(content_types):
  411. """
  412. Return True if a bulk update of custom field data across the given object types is too large
  413. to perform within the request which triggered it, and must be handed to a background job
  414. instead. The limit is BULK_UPDATE_CHUNK_SIZE objects across all of the given types: an
  415. update which fits within a single statement is comfortably within any request timeout.
  416. The rows are probed rather than counted: `COUNT(*)` reads the whole table, whereas counting
  417. one primary key more than the limit costs the same on a table of ten million rows as on one
  418. of ten thousand. Only the primary key is selected, and the model's default ordering cleared,
  419. to keep the probe to an index-only scan.
  420. On the deletion path this over-estimates, as every row of the type is counted where
  421. remove_stale_data() would rewrite only those holding the field's key. Probing the key
  422. instead would match the work exactly, but custom_field_data carries no index, so the LIMIT
  423. could not bound the scan.
  424. """
  425. # Setting BULK_UPDATE_CHUNK_SIZE to None disables chunking, so the update would be issued
  426. # as a single unbounded statement -- precisely what must not run inside a request. Treat any
  427. # affected object as exceeding the limit, handing the work to the job, which issues that one
  428. # statement under a timeout generous enough to survive it (see CUSTOMFIELD_JOB_TIMEOUT). A
  429. # limit of zero leaves the probe below testing for a single row, so a field affecting no
  430. # objects still needs no job.
  431. limit = settings.BULK_UPDATE_CHUNK_SIZE
  432. remaining = 0 if limit is None else limit
  433. for ct in content_types:
  434. if model := ct.model_class():
  435. remaining -= model.objects.order_by().values_list('pk', flat=True)[:remaining + 1].count()
  436. if remaining < 0:
  437. return True
  438. return False
  439. def provision_data(self, object_types):
  440. """
  441. Populate the field's default value across the existing objects of the given object types.
  442. Where too many objects are affected to handle within the request, the field is taken offline
  443. and the backfill handed to a background job: it does not go live until the job has finished
  444. (see CustomFieldStatusChoices).
  445. Assignment to a field which is not live is refused, as CustomField.clean() refuses every
  446. other change to one: its configuration must not move under the job which is acting on it.
  447. Were a second backfill deferred here, it would carry only the object types passed to it, and
  448. whichever of the two jobs ran first would bring the field live -- leaving the other to find
  449. a field it no longer matched, and its own object types silently unprovisioned.
  450. """
  451. from extras.jobs import CustomFieldProvisioningJob
  452. using = router.db_for_write(self.__class__, instance=self)
  453. with transaction.atomic(using=using):
  454. # The status is re-read under a row lock rather than taken from this instance
  455. self.status = self._lock_status(using)
  456. if self.status is None:
  457. # Deleted by a concurrent request since this instance was fetched; there is no field
  458. # left to assign. Reported rather than ignored, as the assignment has not been applied.
  459. raise AbortRequest(
  460. _("Custom field '{name}' no longer exists.").format(name=self.name)
  461. )
  462. if self.status != CustomFieldStatusChoices.STATUS_ACTIVE:
  463. raise AbortRequest(
  464. _("Custom field '{name}' cannot be assigned to additional object types while its "
  465. "stored data is being updated (status: {status}).").format(
  466. name=self.name, status=self.get_status_display().lower()
  467. )
  468. )
  469. if self.default is None:
  470. return
  471. object_types = list(object_types)
  472. if not self._exceeds_inline_limit(object_types):
  473. self.populate_initial_data(object_types)
  474. return
  475. self.status = CustomFieldStatusChoices.STATUS_PROVISIONING
  476. # Applied via the queryset so that taking the field offline does not itself record a change.
  477. self.__class__.objects.using(using).filter(pk=self.pk).update(status=self.status)
  478. self.__class__.objects.clear_cache()
  479. # Deferred until commit so that the worker cannot observe the field before it is marked.
  480. # The types are carried to the job, which cannot otherwise know which of the field's
  481. # assignments are the new ones.
  482. transaction.on_commit(
  483. lambda: CustomFieldProvisioningJob.enqueue_for(
  484. self, object_type_pks=[ct.pk for ct in object_types]
  485. ),
  486. using=using
  487. )
  488. def remove_data(self, object_types):
  489. """
  490. Remove the field's stored data from the existing objects of the given object types, as the
  491. field is unassigned from them.
  492. Unassignment from a field which is not live is refused, as provision_data() refuses an
  493. assignment to one. The job acting on the field's data carries the object types it was given
  494. and would not observe an unassignment made under it: it would write its defaults into objects
  495. the removal had already swept, then bring the field live with values left on objects it no
  496. longer applies to.
  497. Unlike provisioning and deletion, this is never deferred to a job. Only the objects which
  498. actually hold a value for the field are rewritten, which on an unassignment is typically a
  499. small fraction of the table (see the note in the custom fields documentation).
  500. """
  501. using = router.db_for_write(self.__class__, instance=self)
  502. with transaction.atomic(using=using):
  503. # The status is re-read under a row lock rather than taken from this instance, which a
  504. # job may have taken offline since it was fetched, and which must not change between the
  505. # check below and the data being removed.
  506. self.status = self._lock_status(using)
  507. if self.status is None:
  508. # Deleted by a concurrent request since this instance was fetched; whatever data
  509. # remains belongs to the deletion, which removes it in full.
  510. raise AbortRequest(
  511. _("Custom field '{name}' no longer exists.").format(name=self.name)
  512. )
  513. if self.status != CustomFieldStatusChoices.STATUS_ACTIVE:
  514. raise AbortRequest(
  515. _("Custom field '{name}' cannot be unassigned from object types while its "
  516. "stored data is being updated (status: {status}).").format(
  517. name=self.name, status=self.get_status_display().lower()
  518. )
  519. )
  520. self.remove_stale_data(object_types)
  521. def populate_initial_data(self, content_types, commit_per_batch=False):
  522. """
  523. Populate initial custom field data upon either a) the creation of a new CustomField, or
  524. b) the assignment of an existing CustomField to new object types.
  525. Objects which already hold a key for the field are left alone, making this idempotent -- as
  526. a retried job requires, and as committing the backfill in batches relies on. (Note that a
  527. cleared value is a JSON null rather than an absent key, and so is likewise preserved.)
  528. """
  529. if self.default is None:
  530. return
  531. value = Value(self.default, models.JSONField())
  532. for ct in content_types:
  533. if model := ct.model_class():
  534. self._update_object_data(
  535. model,
  536. filters=~Q(custom_field_data__has_key=self.name),
  537. commit_per_batch=commit_per_batch,
  538. custom_field_data=Func(
  539. F('custom_field_data'),
  540. Value([self.name]),
  541. value,
  542. function='jsonb_set'
  543. )
  544. )
  545. def remove_stale_data(self, content_types, commit_per_batch=False):
  546. """
  547. Delete custom field data which is no longer relevant (either because the CustomField is
  548. no longer assigned to a model, or because it has been deleted).
  549. Only objects which actually hold a value for the field are rewritten. That typically excludes
  550. the bulk of the table, and makes this idempotent -- as committing the removal in batches
  551. relies on -- since a row is dropped from the queryset by the update which removes its key.
  552. """
  553. for ct in content_types:
  554. if model := ct.model_class():
  555. self._update_object_data(
  556. model,
  557. filters=Q(custom_field_data__has_key=self.name),
  558. commit_per_batch=commit_per_batch,
  559. custom_field_data=F('custom_field_data') - self.name
  560. )
  561. def rename_object_data(self, old_name, new_name):
  562. """
  563. Called when a CustomField has been renamed. Removes the original key and inserts the new
  564. one, copying the value of the old key.
  565. """
  566. for ct in self.object_types.all():
  567. if model := ct.model_class():
  568. self._update_object_data(
  569. model,
  570. filters=Q(custom_field_data__has_key=old_name),
  571. custom_field_data=Func(
  572. F('custom_field_data') - old_name,
  573. Value([new_name]),
  574. Func(
  575. F('custom_field_data'),
  576. Value(old_name),
  577. function='jsonb_extract_path',
  578. output_field=models.JSONField()
  579. ),
  580. function='jsonb_set')
  581. )
  582. def delete(self, using=None, *args, **kwargs):
  583. """
  584. Delete the field, deferring the removal of its stored data to a background job where too
  585. many objects are affected to handle within the request (see #22996).
  586. Where the work is deferred, the row is retained until the job completes: `name` is unique, so
  587. for as long as the row exists no other field can take this name and inherit the data still
  588. awaiting removal.
  589. The deletion signals are dispatched here rather than when the row is finally removed, so that
  590. protection rules, the change log, event rules and the search index observe the deletion where
  591. the user performed it. They run again in the worker, where every effect beyond the protection
  592. rules is gated on there being a current request, making the replay a no-op.
  593. The deletion is refused outright if a background job holds the field's data lock, rather than
  594. queueing behind that job. This applies equally to a field already pending deletion: reporting
  595. a deletion which did not happen would be worse than refusing it. A field stranded in a pending
  596. state by a job which never ran holds no lock, and stays deletable; retrying the deletion of
  597. one already pending enqueues a fresh purge job for it.
  598. Deleting a field already marked for deletion -- by an earlier request of the user's own, or by
  599. a concurrent one -- removes nothing further and dispatches no second set of deletion signals.
  600. """
  601. from extras.jobs import CustomFieldPurgeJob
  602. using = using or router.db_for_write(self.__class__, instance=self)
  603. with transaction.atomic(using=using):
  604. if not self._try_lock_data(self.pk, using):
  605. raise AbortRequest(
  606. _("Custom field '{name}' is being updated by a background job and cannot be "
  607. "deleted until that job has completed.").format(name=self.name)
  608. )
  609. # The status is re-read under a row lock rather than taken from this instance
  610. self.status = self._lock_status(using)
  611. if self.status is None:
  612. # Already deleted outright by a concurrent request; nothing remains to delete.
  613. return 0, {}
  614. if self.status == CustomFieldStatusChoices.STATUS_DELETING:
  615. # Already pending deletion; the purge job will remove the row once its data is gone.
  616. # The lock being free, no job is *running*, so the one enqueued when the field was
  617. # marked may never have run: enqueue another, delete() being the only route to one.
  618. # Left as it is, a field whose job never ran could never be removed, and would hold
  619. # its name against a replacement indefinitely. Where that job is merely queued (a
  620. # concurrent deletion having just marked the field), the second job is harmless:
  621. # purge_custom_field() rechecks the status under the lock and no-ops.
  622. transaction.on_commit(lambda: CustomFieldPurgeJob.enqueue_for(self), using=using)
  623. return 0, {}
  624. if not self._exceeds_inline_limit(self.object_types.all()):
  625. # Few enough objects to purge within the request: delete the row outright, its
  626. # stored data being removed by handle_cf_deleted().
  627. return super().delete(using, *args, **kwargs)
  628. # Update the custom field's status before the signals are dispatched. Applied via the
  629. # queryset to avoid emitting a spurious "updated" change record.
  630. self.status = CustomFieldStatusChoices.STATUS_DELETING
  631. self.__class__.objects.using(using).filter(pk=self.pk).update(status=self.status)
  632. self.__class__.objects.clear_cache()
  633. models.signals.pre_delete.send(sender=self.__class__, instance=self, using=using, origin=self)
  634. models.signals.post_delete.send(sender=self.__class__, instance=self, using=using, origin=self)
  635. # Deferred until commit so that the worker cannot observe the field before it is marked,
  636. # and is not enqueued at all if the deletion is aborted.
  637. transaction.on_commit(lambda: CustomFieldPurgeJob.enqueue_for(self), using=using)
  638. return 1, {self._meta.label: 1}
  639. def _delete_row(self):
  640. """
  641. Remove the row itself. Called by CustomFieldPurgeJob once the field's stored data has been
  642. purged; nothing else should bypass delete().
  643. """
  644. return super().delete()
  645. def clean(self):
  646. super().clean()
  647. # A field awaiting a bulk update of its stored data is not live, and its configuration must
  648. # not change under the job which is acting on it.
  649. if self.pk and self.status != CustomFieldStatusChoices.STATUS_ACTIVE:
  650. raise ValidationError(
  651. _("Custom field '{name}' cannot be modified while its stored data is being updated "
  652. "(status: {status}).").format(name=self.name, status=self.get_status_display().lower())
  653. )
  654. # Validate the field's default value (if any)
  655. if self.default is not None:
  656. try:
  657. if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
  658. default_value = str(self.default)
  659. else:
  660. default_value = self.default
  661. self.validate(default_value)
  662. except ValidationError as err:
  663. raise ValidationError({
  664. 'default': _(
  665. 'Invalid default value "{value}": {error}'
  666. ).format(value=self.default, error=err.message)
  667. })
  668. # Minimum/maximum values can be set only for numeric fields
  669. if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
  670. if self.validation_minimum:
  671. raise ValidationError({'validation_minimum': _("A minimum value may be set only for numeric fields")})
  672. if self.validation_maximum:
  673. raise ValidationError({'validation_maximum': _("A maximum value may be set only for numeric fields")})
  674. # Regex validation can be set only for text fields
  675. regex_types = (
  676. CustomFieldTypeChoices.TYPE_TEXT,
  677. CustomFieldTypeChoices.TYPE_LONGTEXT,
  678. CustomFieldTypeChoices.TYPE_URL,
  679. )
  680. if self.validation_regex and self.type not in regex_types:
  681. raise ValidationError({
  682. 'validation_regex': _("Regular expression validation is supported only for text and URL fields")
  683. })
  684. # Schema validation can be set only for JSON fields
  685. if self.validation_schema and self.type != CustomFieldTypeChoices.TYPE_JSON:
  686. raise ValidationError({
  687. 'validation_schema': _("JSON schema validation is supported only for JSON fields")
  688. })
  689. # Uniqueness can not be enforced for boolean fields
  690. if self.unique and self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  691. raise ValidationError({
  692. 'unique': _("Uniqueness cannot be enforced for boolean fields")
  693. })
  694. # Choice set must be set on selection fields, and *only* on selection fields
  695. if self.type in (
  696. CustomFieldTypeChoices.TYPE_SELECT,
  697. CustomFieldTypeChoices.TYPE_MULTISELECT
  698. ):
  699. if not self.choice_set:
  700. raise ValidationError({
  701. 'choice_set': _("Selection fields must specify a set of choices.")
  702. })
  703. elif self.choice_set:
  704. raise ValidationError({
  705. 'choice_set': _("Choices may be set only on selection fields.")
  706. })
  707. # Object fields must define an object_type; other fields must not
  708. if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
  709. if not self.related_object_type:
  710. raise ValidationError({
  711. 'related_object_type': _("Object fields must define an object type.")
  712. })
  713. elif self.related_object_type:
  714. raise ValidationError({
  715. 'type': _("{type} fields may not define an object type.") .format(type=self.get_type_display())
  716. })
  717. # Related object filter can be set only for object-type fields, and must contain a dictionary mapping (if set)
  718. if self.related_object_filter is not None:
  719. if self.type not in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
  720. raise ValidationError({
  721. 'related_object_filter': _("A related object filter can be defined only for object fields.")
  722. })
  723. if type(self.related_object_filter) is not dict:
  724. raise ValidationError({
  725. 'related_object_filter': _("Filter must be defined as a dictionary mapping attributes to values.")
  726. })
  727. def serialize(self, value):
  728. """
  729. Prepare a value for storage as JSON data.
  730. """
  731. if value is None:
  732. return value
  733. if self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
  734. return float(value)
  735. if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date:
  736. return value.isoformat()
  737. if self.type == CustomFieldTypeChoices.TYPE_DATETIME and type(value) is datetime:
  738. return value.isoformat()
  739. if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  740. return value.pk
  741. if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  742. return [obj.pk for obj in value] or None
  743. return value
  744. def deserialize(self, value):
  745. """
  746. Convert JSON data to a Python object suitable for the field type.
  747. """
  748. if value is None:
  749. return value
  750. if self.type == CustomFieldTypeChoices.TYPE_DATE:
  751. try:
  752. return date.fromisoformat(value)
  753. except ValueError:
  754. return value
  755. if self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  756. try:
  757. return datetime.fromisoformat(value)
  758. except ValueError:
  759. return value
  760. if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  761. model = self.related_object_type.model_class()
  762. return model.objects.filter(pk=value).first()
  763. if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  764. model = self.related_object_type.model_class()
  765. return model.objects.filter(pk__in=value)
  766. return value
  767. def to_form_field(
  768. self,
  769. set_initial=True,
  770. enforce_required=True,
  771. enforce_visibility=True,
  772. for_csv_import=False,
  773. for_filterset_form=False,
  774. ):
  775. """
  776. Return a form field suitable for setting a CustomField's value for an object.
  777. set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
  778. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
  779. enforce_visibility: Honor the value of CustomField.ui_visible. Set to False for filtering.
  780. for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
  781. for_filterset_form: Return a form field suitable for use in a FilterSet form.
  782. """
  783. initial = self.default if set_initial else None
  784. required = self.required if enforce_required else False
  785. # Integer
  786. if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  787. field = forms.IntegerField(
  788. required=required,
  789. initial=initial,
  790. min_value=self.validation_minimum,
  791. max_value=self.validation_maximum
  792. )
  793. # Decimal
  794. elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
  795. field = forms.DecimalField(
  796. required=required,
  797. initial=initial,
  798. max_digits=16,
  799. decimal_places=4,
  800. min_value=self.validation_minimum,
  801. max_value=self.validation_maximum
  802. )
  803. # Boolean
  804. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  805. choices = (
  806. (None, '---------'),
  807. (True, _('True')),
  808. (False, _('False')),
  809. )
  810. field = forms.NullBooleanField(
  811. required=required, initial=initial, widget=forms.Select(choices=choices)
  812. )
  813. # Date
  814. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  815. field = forms.DateField(required=required, initial=initial, widget=DatePicker())
  816. # Date & time
  817. elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  818. field = forms.DateTimeField(required=required, initial=initial, widget=DateTimePicker())
  819. # Select
  820. elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
  821. choices = self.choice_set.choices
  822. default_choice = self.default if self.default in self.choices else None
  823. if not required or default_choice is None:
  824. choices = add_blank_choice(choices)
  825. # Set the initial value to the first available choice (if any)
  826. if set_initial and default_choice:
  827. initial = default_choice
  828. if for_csv_import:
  829. if self.type == CustomFieldTypeChoices.TYPE_SELECT:
  830. field_class = CSVChoiceField
  831. else:
  832. field_class = CSVMultipleChoiceField
  833. field = field_class(choices=choices, required=required, initial=initial)
  834. else:
  835. if self.type == CustomFieldTypeChoices.TYPE_SELECT and not for_filterset_form:
  836. field_class = DynamicChoiceField
  837. widget_class = APISelect
  838. else:
  839. field_class = DynamicMultipleChoiceField
  840. widget_class = APISelectMultiple
  841. field = field_class(
  842. choices=choices,
  843. required=required,
  844. initial=initial,
  845. widget=widget_class(api_url=f'/api/extras/custom-field-choice-sets/{self.choice_set.pk}/choices/')
  846. )
  847. # URL
  848. elif self.type == CustomFieldTypeChoices.TYPE_URL:
  849. field = LaxURLField(assume_scheme='https', required=required, initial=initial)
  850. if self.validation_regex:
  851. field.validators = [
  852. RegexValidator(
  853. regex=self.validation_regex,
  854. message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
  855. regex=escape(self.validation_regex)
  856. ))
  857. )
  858. ]
  859. # JSON
  860. elif self.type == CustomFieldTypeChoices.TYPE_JSON:
  861. field = JSONField(required=required, initial=json.dumps(initial) if initial is not None else None)
  862. # Object
  863. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  864. model = self.related_object_type.model_class()
  865. if for_csv_import:
  866. field_class = CSVModelChoiceField
  867. elif for_filterset_form:
  868. field_class = DynamicModelMultipleChoiceField
  869. else:
  870. field_class = DynamicModelChoiceField
  871. kwargs = {
  872. 'queryset': model.objects.all(),
  873. 'required': required,
  874. 'initial': initial,
  875. }
  876. if not for_csv_import:
  877. kwargs['query_params'] = self.related_object_filter
  878. kwargs['selector'] = True
  879. field = field_class(**kwargs)
  880. # Multiple objects
  881. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  882. model = self.related_object_type.model_class()
  883. field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
  884. kwargs = {
  885. 'queryset': model.objects.all(),
  886. 'required': required,
  887. 'initial': initial,
  888. }
  889. if not for_csv_import:
  890. kwargs['query_params'] = self.related_object_filter
  891. kwargs['selector'] = True
  892. field = field_class(**kwargs)
  893. # Text
  894. else:
  895. widget = forms.Textarea if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT else None
  896. field = forms.CharField(required=required, initial=initial, widget=widget)
  897. if self.validation_regex:
  898. field.validators = [
  899. RegexValidator(
  900. regex=self.validation_regex,
  901. message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
  902. regex=escape(self.validation_regex)
  903. ))
  904. )
  905. ]
  906. field.model = self
  907. field.label = str(self)
  908. if self.description:
  909. field.help_text = render_markdown(self.description)
  910. # Annotate read-only fields
  911. if enforce_visibility and self.ui_editable != CustomFieldUIEditableChoices.YES:
  912. field.disabled = True
  913. return field
  914. def to_filter(self, lookup_expr=None):
  915. """
  916. Return a django_filters Filter instance suitable for this field type.
  917. :param lookup_expr: Custom lookup expression (optional)
  918. """
  919. # Imported locally as extras.filters imports extras.models
  920. from extras.filters import missing_key_aware_filter_factory
  921. kwargs = {
  922. 'field_name': f'custom_field_data__{self.name}'
  923. }
  924. # Native numeric filters will use `isnull` by default for empty lookups, but
  925. # JSON fields require `empty` (see bug #20012).
  926. if lookup_expr == 'isnull':
  927. lookup_expr = 'empty'
  928. if lookup_expr is not None:
  929. kwargs['lookup_expr'] = lookup_expr
  930. # 'Empty' lookup is always a boolean
  931. if lookup_expr == 'empty':
  932. filter_class = django_filters.BooleanFilter
  933. # Text/URL
  934. elif self.type in (
  935. CustomFieldTypeChoices.TYPE_TEXT,
  936. CustomFieldTypeChoices.TYPE_LONGTEXT,
  937. CustomFieldTypeChoices.TYPE_URL,
  938. ):
  939. filter_class = filters.MultiValueCharFilter
  940. if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
  941. kwargs['lookup_expr'] = 'icontains'
  942. # Integer
  943. elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  944. filter_class = filters.MultiValueNumberFilter
  945. # Decimal
  946. elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
  947. filter_class = filters.MultiValueDecimalFilter
  948. # Boolean
  949. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  950. filter_class = django_filters.BooleanFilter
  951. # Date
  952. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  953. filter_class = filters.MultiValueDateFilter
  954. # Date & time
  955. elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  956. filter_class = filters.MultiValueDateTimeFilter
  957. # Select
  958. elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
  959. filter_class = filters.MultiValueCharFilter
  960. # Multiselect
  961. elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  962. filter_class = filters.MultiValueArrayFilter
  963. # Object
  964. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  965. filter_class = filters.MultiValueNumberFilter
  966. # Multi-object
  967. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  968. filter_class = filters.MultiValueNumberFilter
  969. kwargs['lookup_expr'] = 'contains'
  970. # Unsupported custom field type
  971. else:
  972. return None
  973. # A negated lookup must match objects which carry no key for this field at all; see
  974. # MissingKeyAwareFilterMixin. BooleanFilter is never negated, so it is left alone.
  975. if not issubclass(filter_class, django_filters.BooleanFilter):
  976. filter_class = missing_key_aware_filter_factory(filter_class)
  977. filter_instance = filter_class(**kwargs)
  978. filter_instance.custom_field = self
  979. return filter_instance
  980. def validate(self, value):
  981. """
  982. Validate a value according to the field's type validation rules.
  983. """
  984. if value not in [None, '']:
  985. # Validate text field
  986. if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
  987. if type(value) is not str:
  988. raise ValidationError(_("Value must be a string."))
  989. if self.validation_regex and not re.match(self.validation_regex, value):
  990. raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
  991. # Validate URL field
  992. elif self.type == CustomFieldTypeChoices.TYPE_URL:
  993. if type(value) is not str:
  994. raise ValidationError(_("Value must be a string."))
  995. # Enforce ALLOWED_URL_SCHEMES to guard against dangerous schemes (e.g. javascript:). A
  996. # schemeless value is permitted and treated as relative.
  997. if not url_scheme_is_allowed(value):
  998. raise ValidationError(
  999. _("URLs must use a scheme permitted by ALLOWED_URL_SCHEMES.")
  1000. )
  1001. if self.validation_regex and not re.match(self.validation_regex, value):
  1002. raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
  1003. # Validate integer
  1004. elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  1005. if type(value) is not int:
  1006. raise ValidationError(_("Value must be an integer."))
  1007. if self.validation_minimum is not None and value < self.validation_minimum:
  1008. raise ValidationError(
  1009. _("Value must be at least {minimum}").format(minimum=self.validation_minimum)
  1010. )
  1011. if self.validation_maximum is not None and value > self.validation_maximum:
  1012. raise ValidationError(
  1013. _("Value must not exceed {maximum}").format(maximum=self.validation_maximum)
  1014. )
  1015. # Validate decimal
  1016. elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
  1017. try:
  1018. decimal.Decimal(value)
  1019. except decimal.InvalidOperation:
  1020. raise ValidationError(_("Value must be a decimal."))
  1021. if self.validation_minimum is not None and value < self.validation_minimum:
  1022. raise ValidationError(
  1023. _("Value must be at least {minimum}").format(minimum=self.validation_minimum)
  1024. )
  1025. if self.validation_maximum is not None and value > self.validation_maximum:
  1026. raise ValidationError(
  1027. _("Value must not exceed {maximum}").format(maximum=self.validation_maximum)
  1028. )
  1029. # Validate boolean
  1030. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
  1031. raise ValidationError(_("Value must be true or false."))
  1032. # Validate date
  1033. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  1034. if type(value) is not date:
  1035. try:
  1036. date.fromisoformat(value)
  1037. except ValueError:
  1038. raise ValidationError(_("Date values must be in ISO 8601 format (YYYY-MM-DD)."))
  1039. # Validate date & time
  1040. elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
  1041. if type(value) is not datetime:
  1042. try:
  1043. datetime_from_timestamp(value)
  1044. except ValueError:
  1045. raise ValidationError(
  1046. _("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).")
  1047. )
  1048. # Validate selected choice
  1049. elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
  1050. if value not in self.choice_set.values:
  1051. raise ValidationError(
  1052. _("Invalid choice ({value}) for choice set {choiceset}.").format(
  1053. value=value,
  1054. choiceset=self.choice_set
  1055. )
  1056. )
  1057. # Validate all selected choices
  1058. elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  1059. # Require a list of valid string choices. The isinstance() check short-circuits the membership
  1060. # test so that non-string members (e.g. a client echoing back the {value, label} read
  1061. # representation) raise a ValidationError rather than an unhashable-type TypeError.
  1062. valid_values = set(self.choice_set.values)
  1063. if type(value) is not list or not all(isinstance(v, str) and v in valid_values for v in value):
  1064. raise ValidationError(
  1065. _("Invalid choice(s) ({value}) for choice set {choiceset}.").format(
  1066. value=value,
  1067. choiceset=self.choice_set
  1068. )
  1069. )
  1070. # Validate selected object
  1071. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  1072. if type(value) is not int:
  1073. raise ValidationError(_("Value must be an object ID, not {type}").format(type=type(value).__name__))
  1074. # Validate selected objects
  1075. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  1076. if type(value) is not list:
  1077. raise ValidationError(
  1078. _("Value must be a list of object IDs, not {type}").format(type=type(value).__name__)
  1079. )
  1080. for id in value:
  1081. if type(id) is not int:
  1082. raise ValidationError(_("Found invalid object ID: {id}").format(id=id))
  1083. # Validate JSON against schema (if defined)
  1084. elif self.type == CustomFieldTypeChoices.TYPE_JSON:
  1085. if self.validation_schema:
  1086. try:
  1087. jsonschema.validate(value, schema=self.validation_schema)
  1088. except JSONValidationError as e:
  1089. raise ValidationError(
  1090. _("Value does not conform to the assigned schema: {error}").format(error=e.message)
  1091. )
  1092. elif self.required:
  1093. raise ValidationError(_("Required field cannot be empty."))
  1094. class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedModel):
  1095. """
  1096. Represents a set of choices available for choice and multi-choice custom fields.
  1097. """
  1098. name = models.CharField(
  1099. max_length=100,
  1100. unique=True
  1101. )
  1102. description = models.CharField(
  1103. max_length=200,
  1104. blank=True
  1105. )
  1106. base_choices = models.CharField(
  1107. max_length=50,
  1108. choices=CustomFieldChoiceSetBaseChoices,
  1109. blank=True,
  1110. null=True,
  1111. help_text=_('Base set of predefined choices (optional)')
  1112. )
  1113. extra_choices = ChoiceSetField(
  1114. blank=True,
  1115. null=True
  1116. )
  1117. choice_colors = models.JSONField(
  1118. default=dict,
  1119. blank=True,
  1120. )
  1121. order_alphabetically = models.BooleanField(
  1122. default=False,
  1123. help_text=_('Choices are automatically ordered alphabetically')
  1124. )
  1125. clone_fields = ('extra_choices', 'choice_colors', 'order_alphabetically')
  1126. class Meta:
  1127. ordering = ('name',)
  1128. verbose_name = _('custom field choice set')
  1129. verbose_name_plural = _('custom field choice sets')
  1130. def __str__(self):
  1131. return self.name
  1132. def __init__(self, *args, **kwargs):
  1133. super().__init__(*args, **kwargs)
  1134. # Cache the initial set of choices for comparison under clean()
  1135. self._original_extra_choices = self.__dict__.get('extra_choices')
  1136. def get_absolute_url(self):
  1137. return reverse('extras:customfieldchoiceset', args=[self.pk])
  1138. @property
  1139. def choices(self):
  1140. """
  1141. Returns a concatenation of the base and extra choices.
  1142. """
  1143. if not hasattr(self, '_choices'):
  1144. self._choices = []
  1145. if self.base_choices:
  1146. self._choices.extend(CHOICE_SETS.get(self.base_choices))
  1147. if self.extra_choices:
  1148. self._choices.extend(self.extra_choices)
  1149. if self.order_alphabetically:
  1150. self._choices = sorted(self._choices, key=lambda x: x[0])
  1151. return self._choices
  1152. @property
  1153. def colors(self):
  1154. """
  1155. Return merged color mappings from the selected base choice set (if it defines colors)
  1156. and any custom color overrides defined on this choice set.
  1157. """
  1158. if not hasattr(self, '_colors'):
  1159. self._colors = {}
  1160. if self.base_choices:
  1161. base_choice_set = CHOICE_SETS.get(self.base_choices)
  1162. self._colors.update(getattr(base_choice_set, 'colors', {}))
  1163. if self.choice_colors:
  1164. self._colors.update(self.choice_colors)
  1165. return self._colors
  1166. def get_choice_color(self, value):
  1167. return self.colors.get(value)
  1168. @property
  1169. def choices_count(self):
  1170. return len(self.choices)
  1171. @property
  1172. def values(self):
  1173. """
  1174. Returns an iterator of the valid choice values.
  1175. """
  1176. return (x[0] for x in self.choices)
  1177. def clean(self):
  1178. if not self.base_choices and not self.extra_choices:
  1179. raise ValidationError(_("Must define base or extra choices."))
  1180. if self.choice_colors is None:
  1181. self.choice_colors = {}
  1182. elif not isinstance(self.choice_colors, dict):
  1183. raise ValidationError({
  1184. 'choice_colors': _('Color mappings must be defined as a JSON object.')
  1185. })
  1186. valid_choice_values = set()
  1187. extra_choice_values = set()
  1188. if self.base_choices:
  1189. valid_choice_values.update(value for value, _ in CHOICE_SETS.get(self.base_choices))
  1190. if self.extra_choices:
  1191. for value, _label in self.extra_choices:
  1192. if value in extra_choice_values:
  1193. raise ValidationError(_("Duplicate value '{value}' found in extra choices.").format(value=value))
  1194. extra_choice_values.add(value)
  1195. valid_choice_values.update(extra_choice_values)
  1196. invalid_choice_values = set()
  1197. invalid_colors = set()
  1198. valid_colors = set(CustomFieldChoiceColorChoices.values())
  1199. for value, color in self.choice_colors.items():
  1200. if value not in valid_choice_values:
  1201. invalid_choice_values.add(value)
  1202. if color not in valid_colors:
  1203. invalid_colors.add(color)
  1204. if invalid_choice_values:
  1205. raise ValidationError({
  1206. 'choice_colors': _(
  1207. 'Color mappings must reference an existing choice value. Invalid value(s): {values}.'
  1208. ).format(values=', '.join(sorted(invalid_choice_values)))
  1209. })
  1210. if invalid_colors:
  1211. raise ValidationError({
  1212. 'choice_colors': _(
  1213. 'Invalid color value(s): {colors}. Use a supported named color.'
  1214. ).format(colors=', '.join(sorted(invalid_colors)))
  1215. })
  1216. # Check whether any choices have been removed. If so, check whether any of the removed
  1217. # choices are still set in custom field data for any object.
  1218. original_choices = set([
  1219. c[0] for c in self._original_extra_choices
  1220. ]) if self._original_extra_choices else set()
  1221. if removed_choices := original_choices - valid_choice_values:
  1222. for custom_field in self.choices_for.all():
  1223. for object_type in custom_field.object_types.all():
  1224. model = object_type.model_class()
  1225. for choice in removed_choices:
  1226. # Form the query based on the type of custom field
  1227. if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  1228. query_args = {f"custom_field_data__{custom_field.name}__contains": choice}
  1229. else:
  1230. query_args = {f"custom_field_data__{custom_field.name}": choice}
  1231. # Raise a ValidationError if there are any objects which still reference the removed choice
  1232. if model.objects.filter(models.Q(**query_args)).exists():
  1233. raise ValidationError(
  1234. _(
  1235. "Cannot remove choice {choice} as there are {model} objects which reference it."
  1236. ).format(choice=choice, model=object_type)
  1237. )
  1238. def save(self, *args, **kwargs):
  1239. # Sort choices if alphabetical ordering is enforced
  1240. if self.order_alphabetically and self.extra_choices:
  1241. self.extra_choices = sorted(self.extra_choices, key=lambda x: x[0])
  1242. return super().save(*args, **kwargs)