Coverage for extras/models/customfields.py: 18%

559 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import copy 

2import decimal 

3import json 

4import re 

5from datetime import date, datetime 

6 

7import django_filters 

8import jsonschema 

9from django import forms 

10from django.conf import settings 

11from django.core.validators import RegexValidator, ValidationError 

12from django.db import connections, models, router, transaction 

13from django.db.models import F, Func, Q, Value 

14from django.urls import reverse 

15from django.utils.html import escape 

16from django.utils.safestring import mark_safe 

17from django.utils.translation import gettext_lazy as _ 

18from jsonschema.exceptions import ValidationError as JSONValidationError 

19 

20from core.models import ObjectType 

21from extras.choices import * 

22from extras.data import CHOICE_SETS 

23from extras.fields import ChoiceSetField 

24from netbox.constants import ADVISORY_LOCK_KEYS 

25from netbox.context import query_cache 

26from netbox.models import ChangeLoggedModel 

27from netbox.models.features import CloningMixin, ExportTemplatesMixin 

28from netbox.models.mixins import OwnerMixin 

29from netbox.search import FieldTypes 

30from utilities import filters 

31from utilities.datetime import datetime_from_timestamp 

32from utilities.exceptions import AbortRequest 

33from utilities.forms.fields import ( 

34 CSVChoiceField, 

35 CSVModelChoiceField, 

36 CSVModelMultipleChoiceField, 

37 CSVMultipleChoiceField, 

38 DynamicChoiceField, 

39 DynamicModelChoiceField, 

40 DynamicModelMultipleChoiceField, 

41 DynamicMultipleChoiceField, 

42 JSONField, 

43 LaxURLField, 

44) 

45from utilities.forms.utils import add_blank_choice 

46from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker 

47from utilities.jsonschema import validate_schema 

48from utilities.querysets import RestrictedQuerySet, chunked_update 

49from utilities.templatetags.builtins.filters import render_markdown 

50from utilities.validators import url_scheme_is_allowed, validate_regex 

51 

52__all__ = ( 

53 'CustomField', 

54 'CustomFieldChoiceSet', 

55 'CustomFieldManager', 

56) 

57 

58SEARCH_TYPES = { 

59 CustomFieldTypeChoices.TYPE_TEXT: FieldTypes.STRING, 

60 CustomFieldTypeChoices.TYPE_LONGTEXT: FieldTypes.STRING, 

61 CustomFieldTypeChoices.TYPE_INTEGER: FieldTypes.INTEGER, 

62 CustomFieldTypeChoices.TYPE_DECIMAL: FieldTypes.FLOAT, 

63 CustomFieldTypeChoices.TYPE_DATE: FieldTypes.STRING, 

64 CustomFieldTypeChoices.TYPE_URL: FieldTypes.STRING, 

65} 

66 

67 

68class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): 

69 use_in_migrations = True 

70 

71 def get_for_model(self, model, statuses=(CustomFieldStatusChoices.STATUS_ACTIVE,)): 

72 """ 

73 Return a list of the CustomFields assigned to the given model which hold one of the given 

74 statuses. 

75 

76 Only active fields are returned by default: a field awaiting a bulk update of its stored data 

77 is not live, and must be invisible to every consumer of custom field data until that work 

78 completes (see CustomFieldStatusChoices). This is the sole entry point by which custom fields 

79 are resolved for an object, so excluding them here excludes them everywhere. 

80 

81 Every assigned field is fetched and cached whichever statuses are asked for, so that callers 

82 wanting different subsets share one query per model per request. 

83 

84 Args: 

85 model: The model whose custom fields are to be returned 

86 statuses: The statuses to select (active only by default) 

87 """ 

88 cache = query_cache.get() 

89 

90 # Check the request cache before hitting the database. Test the cached value against None 

91 # rather than for truthiness: a model with no custom fields caches an empty list, which 

92 # would otherwise be treated as a miss and re-queried on every call. 

93 custom_fields = cache['custom_fields'].get(model._meta.model) if cache is not None else None 

94 if custom_fields is None: 

95 content_type = ObjectType.objects.get_for_model(model._meta.concrete_model) 

96 custom_fields = list( 

97 self.get_queryset().filter(object_types=content_type).select_related( 

98 'related_object_type', 'choice_set' 

99 ) 

100 ) 

101 

102 # Populate the request cache to avoid redundant lookups 

103 if cache is not None: 

104 cache['custom_fields'][model._meta.model] = custom_fields 

105 

106 return [cf for cf in custom_fields if cf.status in statuses] 

107 

108 def get_defaults_for_model(self, model): 

109 """ 

110 Return a dictionary of serialized default values for all CustomFields applicable to the given model. 

111 

112 Fields still being provisioned are included, unlike in get_for_model(). The provisioning job 

113 backfills only the objects which predate the field, so an object created while it runs must 

114 pick up the default here or never receive one at all. 

115 

116 The defaults are assembled on each call from the fields cached by get_for_model() rather than 

117 cached in their own right: building them costs a pass over a handful of objects already in 

118 memory, where a second cache would have to be kept coherent with the first. 

119 """ 

120 custom_fields = self.get_for_model(model, statuses=CustomFieldStatusChoices.DATA_STATUSES) 

121 

122 # Copied so that a mutable default cannot be aliased into the object data of every object 

123 # which takes it, the fields above being cached for the life of the request. 

124 return { 

125 cf.name: copy.deepcopy(cf.default) for cf in custom_fields if cf.default is not None 

126 } 

127 

128 @staticmethod 

129 def clear_cache(): 

130 """ 

131 Discard the custom fields cached for the current request, so that a subsequent read reflects 

132 a change which has been applied to the database without passing through save(). 

133 

134 Called wherever a field's status is written directly (see CustomFieldStatusChoices): the 

135 cache spans the whole of a request -- and the whole of a script or job run -- so a field 

136 taken offline, brought live, or marked for deletion partway through one would otherwise 

137 remain visible, or invisible, to everything which followed it there. 

138 """ 

139 if (cache := query_cache.get()) is not None: 

140 cache['custom_fields'].clear() 

141 

142 

143class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedModel): 

144 object_types = models.ManyToManyField( 

145 to='contenttypes.ContentType', 

146 related_name='custom_fields', 

147 help_text=_('The object(s) to which this field applies.') 

148 ) 

149 type = models.CharField( 

150 verbose_name=_('type'), 

151 max_length=50, 

152 choices=CustomFieldTypeChoices, 

153 default=CustomFieldTypeChoices.TYPE_TEXT, 

154 help_text=_('The type of data this custom field holds') 

155 ) 

156 related_object_type = models.ForeignKey( 

157 to='contenttypes.ContentType', 

158 on_delete=models.PROTECT, 

159 blank=True, 

160 null=True, 

161 help_text=_('The type of NetBox object this field maps to (for object fields)') 

162 ) 

163 name = models.CharField( 

164 verbose_name=_('name'), 

165 max_length=50, 

166 unique=True, 

167 help_text=_('Internal field name'), 

168 validators=( 

169 RegexValidator( 

170 regex=r'^[a-z0-9_]+$', 

171 message=_("Only alphanumeric characters and underscores are allowed."), 

172 flags=re.IGNORECASE 

173 ), 

174 RegexValidator( 

175 regex=r'__', 

176 message=_("Double underscores are not permitted in custom field names."), 

177 flags=re.IGNORECASE, 

178 inverse_match=True 

179 ), 

180 ) 

181 ) 

182 status = models.CharField( 

183 max_length=50, 

184 choices=CustomFieldStatusChoices, 

185 default=CustomFieldStatusChoices.STATUS_ACTIVE, 

186 verbose_name=_('status'), 

187 help_text=_("Operational state of the field"), 

188 editable=False 

189 ) 

190 label = models.CharField( 

191 verbose_name=_('label'), 

192 max_length=50, 

193 blank=True, 

194 help_text=_( 

195 "Name of the field as displayed to users (if not provided, 'the field's name will be used)" 

196 ) 

197 ) 

198 group_name = models.CharField( 

199 verbose_name=_('group name'), 

200 max_length=50, 

201 blank=True, 

202 help_text=_("Custom fields within the same group will be displayed together") 

203 ) 

204 description = models.CharField( 

205 verbose_name=_('description'), 

206 max_length=200, 

207 blank=True 

208 ) 

209 required = models.BooleanField( 

210 verbose_name=_('required'), 

211 default=False, 

212 help_text=_("This field is required when creating new objects or editing an existing object.") 

213 ) 

214 unique = models.BooleanField( 

215 verbose_name=_('must be unique'), 

216 default=False, 

217 help_text=_("The value of this field must be unique for the assigned object") 

218 ) 

219 search_weight = models.PositiveSmallIntegerField( 

220 verbose_name=_('search weight'), 

221 default=1000, 

222 help_text=_( 

223 "Weighting for search. Lower values are considered more important. Fields with a search weight of zero " 

224 "will be ignored." 

225 ) 

226 ) 

227 filter_logic = models.CharField( 

228 verbose_name=_('filter logic'), 

229 max_length=50, 

230 choices=CustomFieldFilterLogicChoices, 

231 default=CustomFieldFilterLogicChoices.FILTER_LOOSE, 

232 help_text=_("Loose matches any instance of a given string; exact matches the entire field.") 

233 ) 

234 default = models.JSONField( 

235 verbose_name=_('default'), 

236 blank=True, 

237 null=True, 

238 help_text=_( 

239 'Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. "Foo").' 

240 ) 

241 ) 

242 related_object_filter = models.JSONField( 

243 blank=True, 

244 null=True, 

245 help_text=_( 

246 'Filter the object selection choices using a query_params dict (must be a JSON value).' 

247 'Encapsulate strings with double quotes (e.g. "Foo").' 

248 ) 

249 ) 

250 weight = models.PositiveSmallIntegerField( 

251 default=100, 

252 verbose_name=_('display weight'), 

253 help_text=_('Fields with higher weights appear lower in a form.') 

254 ) 

255 validation_minimum = models.DecimalField( 

256 max_digits=16, 

257 decimal_places=4, 

258 blank=True, 

259 null=True, 

260 verbose_name=_('minimum value'), 

261 help_text=_('Minimum allowed value (for numeric fields)') 

262 ) 

263 validation_maximum = models.DecimalField( 

264 max_digits=16, 

265 decimal_places=4, 

266 blank=True, 

267 null=True, 

268 verbose_name=_('maximum value'), 

269 help_text=_('Maximum allowed value (for numeric fields)') 

270 ) 

271 validation_regex = models.CharField( 

272 blank=True, 

273 validators=[validate_regex], 

274 max_length=500, 

275 verbose_name=_('validation regex'), 

276 help_text=_( 

277 'Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. For ' 

278 'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.' 

279 ) 

280 ) 

281 validation_schema = models.JSONField( 

282 blank=True, 

283 null=True, 

284 validators=[validate_schema], 

285 verbose_name=_('validation schema'), 

286 help_text=_('A JSON schema definition for validating the custom field value') 

287 ) 

288 choice_set = models.ForeignKey( 

289 to='CustomFieldChoiceSet', 

290 on_delete=models.PROTECT, 

291 related_name='choices_for', 

292 verbose_name=_('choice set'), 

293 blank=True, 

294 null=True 

295 ) 

296 ui_visible = models.CharField( 

297 max_length=50, 

298 choices=CustomFieldUIVisibleChoices, 

299 default=CustomFieldUIVisibleChoices.ALWAYS, 

300 verbose_name=_('UI visible'), 

301 help_text=_('Specifies whether the custom field is displayed in the UI') 

302 ) 

303 ui_editable = models.CharField( 

304 max_length=50, 

305 choices=CustomFieldUIEditableChoices, 

306 default=CustomFieldUIEditableChoices.YES, 

307 verbose_name=_('UI editable'), 

308 help_text=_('Specifies whether the custom field value can be edited in the UI') 

309 ) 

310 is_cloneable = models.BooleanField( 

311 default=False, 

312 verbose_name=_('is cloneable'), 

313 help_text=_('Replicate this value when cloning objects') 

314 ) 

315 nulls_first = models.BooleanField( 

316 default=True, 

317 verbose_name=_('nulls first'), 

318 help_text=_('Sort null values before non-null values when ordering by this field') 

319 ) 

320 comments = models.TextField( 

321 verbose_name=_('comments'), 

322 blank=True 

323 ) 

324 

325 objects = CustomFieldManager() 

326 

327 clone_fields = ( 

328 'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'unique', 

329 'search_weight', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 

330 'validation_regex', 'validation_schema', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable', 

331 'nulls_first', 

332 ) 

333 

334 class Meta: 

335 ordering = ['group_name', 'weight', 'name'] 

336 indexes = ( 

337 models.Index(fields=('group_name', 'weight', 'name')), # Default ordering 

338 ) 

339 verbose_name = _('custom field') 

340 verbose_name_plural = _('custom fields') 

341 

342 def __str__(self): 

343 return self.label or self.name.replace('_', ' ').capitalize() 

344 

345 def get_absolute_url(self): 

346 return reverse('extras:customfield', args=[self.pk]) 

347 

348 @property 

349 def docs_url(self): 

350 return f'{settings.STATIC_URL}docs/models/extras/customfield/' 

351 

352 def __init__(self, *args, **kwargs): 

353 super().__init__(*args, **kwargs) 

354 

355 # Cache instance's original name so we can check later whether it has changed 

356 self._name = self.__dict__.get('name') 

357 

358 @property 

359 def search_type(self): 

360 return SEARCH_TYPES.get(self.type) 

361 

362 @property 

363 def choices(self): 

364 if self.choice_set: 

365 return self.choice_set.choices 

366 return [] 

367 

368 def get_status_color(self): 

369 return CustomFieldStatusChoices.colors.get(self.status) 

370 

371 def get_ui_visible_color(self): 

372 return CustomFieldUIVisibleChoices.colors.get(self.ui_visible) 

373 

374 def get_ui_editable_color(self): 

375 return CustomFieldUIEditableChoices.colors.get(self.ui_editable) 

376 

377 def get_choice_label(self, value): 

378 if not hasattr(self, '_choice_map'): 

379 self._choice_map = dict(self.choices) 

380 return self._choice_map.get(value, value) 

381 

382 def get_choice_color(self, value): 

383 if self.choice_set: 

384 return self.choice_set.get_choice_color(value) 

385 return None 

386 

387 def resolve_selection_value(self, value): 

388 """ 

389 For a Selection or Multiple selection field, wrap the value(s) with their resolved label as 

390 {'value': ..., 'label': ...} (a list thereof for multi-select). Other field types pass through 

391 unchanged. Shared by the REST API and GraphQL so selection labels resolve consistently (#20897). 

392 """ 

393 if value is None: 

394 return value 

395 if self.type == CustomFieldTypeChoices.TYPE_SELECT: 

396 return {'value': value, 'label': self.get_choice_label(value)} 

397 if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: 

398 return [{'value': v, 'label': self.get_choice_label(v)} for v in value] 

399 return value 

400 

401 @staticmethod 

402 def data_lock_key(pk): 

403 """ 

404 The advisory lock which serializes bulk updates of a field's stored data against one another 

405 and against its deletion, keyed by primary key so that work on one field never waits on 

406 another. 

407 """ 

408 return ADVISORY_LOCK_KEYS['custom-field-data'], pk 

409 

410 @classmethod 

411 def _try_lock_data(cls, pk, using): 

412 """ 

413 Take the field's data lock at transaction scope, returning False if it is held elsewhere. 

414 Never waits: a job holds this lock for the duration of its bulk update, which may run for 

415 hours (see CUSTOMFIELD_JOB_TIMEOUT). 

416 """ 

417 with connections[using].cursor() as cursor: 

418 cursor.execute('SELECT pg_try_advisory_xact_lock(%s, %s)', cls.data_lock_key(pk)) 

419 return cursor.fetchone()[0] 

420 

421 def _lock_status(self, using): 

422 """ 

423 Re-read the field's status under a row lock, returning None where the row no longer exists. 

424 

425 The status is not taken from this instance, which a job or a concurrent request may have 

426 changed since it was fetched, and which must not change between being checked by the caller 

427 and the field being marked below. 

428 """ 

429 return self.__class__.objects.using(using).select_for_update().filter( 

430 pk=self.pk 

431 ).values_list('status', flat=True).first() 

432 

433 @staticmethod 

434 def _update_object_data(model, filters=None, commit_per_batch=False, **update_kwargs): 

435 """ 

436 Apply an UPDATE to the custom_field_data of every instance of the given model, in batches 

437 of at most BULK_UPDATE_CHUNK_SIZE rows. Bounding the number of rows touched by each statement 

438 keeps a very large table from exceeding the database statement timeout, as a JSONB update 

439 rewrites each affected row in full. 

440 

441 :param filters: Optional Q object restricting which rows are updated. Negate it to address 

442 the rows which do not match instead. 

443 :param commit_per_batch: Commit each batch independently rather than wrapping them all in a 

444 single transaction, so that a long-running job does not hold row locks for its whole 

445 duration. Only for updates which can safely be resumed. 

446 """ 

447 return chunked_update( 

448 model.objects.filter(filters or Q()), 

449 commit_per_batch=commit_per_batch, 

450 **update_kwargs, 

451 ) 

452 

453 @staticmethod 

454 def _exceeds_inline_limit(content_types): 

455 """ 

456 Return True if a bulk update of custom field data across the given object types is too large 

457 to perform within the request which triggered it, and must be handed to a background job 

458 instead. The limit is BULK_UPDATE_CHUNK_SIZE objects across all of the given types: an 

459 update which fits within a single statement is comfortably within any request timeout. 

460 

461 The rows are probed rather than counted: `COUNT(*)` reads the whole table, whereas counting 

462 one primary key more than the limit costs the same on a table of ten million rows as on one 

463 of ten thousand. Only the primary key is selected, and the model's default ordering cleared, 

464 to keep the probe to an index-only scan. 

465 

466 On the deletion path this over-estimates, as every row of the type is counted where 

467 remove_stale_data() would rewrite only those holding the field's key. Probing the key 

468 instead would match the work exactly, but custom_field_data carries no index, so the LIMIT 

469 could not bound the scan. 

470 """ 

471 # Setting BULK_UPDATE_CHUNK_SIZE to None disables chunking, so the update would be issued 

472 # as a single unbounded statement -- precisely what must not run inside a request. Treat any 

473 # affected object as exceeding the limit, handing the work to the job, which issues that one 

474 # statement under a timeout generous enough to survive it (see CUSTOMFIELD_JOB_TIMEOUT). A 

475 # limit of zero leaves the probe below testing for a single row, so a field affecting no 

476 # objects still needs no job. 

477 limit = settings.BULK_UPDATE_CHUNK_SIZE 

478 remaining = 0 if limit is None else limit 

479 

480 for ct in content_types: 

481 if model := ct.model_class(): 

482 remaining -= model.objects.order_by().values_list('pk', flat=True)[:remaining + 1].count() 

483 if remaining < 0: 

484 return True 

485 return False 

486 

487 def provision_data(self, object_types): 

488 """ 

489 Populate the field's default value across the existing objects of the given object types. 

490 

491 Where too many objects are affected to handle within the request, the field is taken offline 

492 and the backfill handed to a background job: it does not go live until the job has finished 

493 (see CustomFieldStatusChoices). 

494 

495 Assignment to a field which is not live is refused, as CustomField.clean() refuses every 

496 other change to one: its configuration must not move under the job which is acting on it. 

497 Were a second backfill deferred here, it would carry only the object types passed to it, and 

498 whichever of the two jobs ran first would bring the field live -- leaving the other to find 

499 a field it no longer matched, and its own object types silently unprovisioned. 

500 """ 

501 from extras.jobs import CustomFieldProvisioningJob 

502 

503 using = router.db_for_write(self.__class__, instance=self) 

504 

505 with transaction.atomic(using=using): 

506 

507 # The status is re-read under a row lock rather than taken from this instance 

508 self.status = self._lock_status(using) 

509 if self.status is None: 

510 # Deleted by a concurrent request since this instance was fetched; there is no field 

511 # left to assign. Reported rather than ignored, as the assignment has not been applied. 

512 raise AbortRequest( 

513 _("Custom field '{name}' no longer exists.").format(name=self.name) 

514 ) 

515 

516 if self.status != CustomFieldStatusChoices.STATUS_ACTIVE: 

517 raise AbortRequest( 

518 _("Custom field '{name}' cannot be assigned to additional object types while its " 

519 "stored data is being updated (status: {status}).").format( 

520 name=self.name, status=self.get_status_display().lower() 

521 ) 

522 ) 

523 

524 if self.default is None: 

525 return 

526 

527 object_types = list(object_types) 

528 if not self._exceeds_inline_limit(object_types): 

529 self.populate_initial_data(object_types) 

530 return 

531 

532 self.status = CustomFieldStatusChoices.STATUS_PROVISIONING 

533 # Applied via the queryset so that taking the field offline does not itself record a change. 

534 self.__class__.objects.using(using).filter(pk=self.pk).update(status=self.status) 

535 self.__class__.objects.clear_cache() 

536 

537 # Deferred until commit so that the worker cannot observe the field before it is marked. 

538 # The types are carried to the job, which cannot otherwise know which of the field's 

539 # assignments are the new ones. 

540 transaction.on_commit( 

541 lambda: CustomFieldProvisioningJob.enqueue_for( 

542 self, object_type_pks=[ct.pk for ct in object_types] 

543 ), 

544 using=using 

545 ) 

546 

547 def remove_data(self, object_types): 

548 """ 

549 Remove the field's stored data from the existing objects of the given object types, as the 

550 field is unassigned from them. 

551 

552 Unassignment from a field which is not live is refused, as provision_data() refuses an 

553 assignment to one. The job acting on the field's data carries the object types it was given 

554 and would not observe an unassignment made under it: it would write its defaults into objects 

555 the removal had already swept, then bring the field live with values left on objects it no 

556 longer applies to. 

557 

558 Unlike provisioning and deletion, this is never deferred to a job. Only the objects which 

559 actually hold a value for the field are rewritten, which on an unassignment is typically a 

560 small fraction of the table (see the note in the custom fields documentation). 

561 """ 

562 using = router.db_for_write(self.__class__, instance=self) 

563 

564 with transaction.atomic(using=using): 

565 

566 # The status is re-read under a row lock rather than taken from this instance, which a 

567 # job may have taken offline since it was fetched, and which must not change between the 

568 # check below and the data being removed. 

569 self.status = self._lock_status(using) 

570 

571 if self.status is None: 

572 # Deleted by a concurrent request since this instance was fetched; whatever data 

573 # remains belongs to the deletion, which removes it in full. 

574 raise AbortRequest( 

575 _("Custom field '{name}' no longer exists.").format(name=self.name) 

576 ) 

577 

578 if self.status != CustomFieldStatusChoices.STATUS_ACTIVE: 

579 raise AbortRequest( 

580 _("Custom field '{name}' cannot be unassigned from object types while its " 

581 "stored data is being updated (status: {status}).").format( 

582 name=self.name, status=self.get_status_display().lower() 

583 ) 

584 ) 

585 

586 self.remove_stale_data(object_types) 

587 

588 def populate_initial_data(self, content_types, commit_per_batch=False): 

589 """ 

590 Populate initial custom field data upon either a) the creation of a new CustomField, or 

591 b) the assignment of an existing CustomField to new object types. 

592 

593 Objects which already hold a key for the field are left alone, making this idempotent -- as 

594 a retried job requires, and as committing the backfill in batches relies on. (Note that a 

595 cleared value is a JSON null rather than an absent key, and so is likewise preserved.) 

596 """ 

597 if self.default is None: 

598 return 

599 

600 value = Value(self.default, models.JSONField()) 

601 for ct in content_types: 

602 if model := ct.model_class(): 

603 self._update_object_data( 

604 model, 

605 filters=~Q(custom_field_data__has_key=self.name), 

606 commit_per_batch=commit_per_batch, 

607 custom_field_data=Func( 

608 F('custom_field_data'), 

609 Value([self.name]), 

610 value, 

611 function='jsonb_set' 

612 ) 

613 ) 

614 

615 def remove_stale_data(self, content_types, commit_per_batch=False): 

616 """ 

617 Delete custom field data which is no longer relevant (either because the CustomField is 

618 no longer assigned to a model, or because it has been deleted). 

619 

620 Only objects which actually hold a value for the field are rewritten. That typically excludes 

621 the bulk of the table, and makes this idempotent -- as committing the removal in batches 

622 relies on -- since a row is dropped from the queryset by the update which removes its key. 

623 """ 

624 for ct in content_types: 

625 if model := ct.model_class(): 

626 self._update_object_data( 

627 model, 

628 filters=Q(custom_field_data__has_key=self.name), 

629 commit_per_batch=commit_per_batch, 

630 custom_field_data=F('custom_field_data') - self.name 

631 ) 

632 

633 def rename_object_data(self, old_name, new_name): 

634 """ 

635 Called when a CustomField has been renamed. Removes the original key and inserts the new 

636 one, copying the value of the old key. 

637 """ 

638 for ct in self.object_types.all(): 

639 if model := ct.model_class(): 

640 self._update_object_data( 

641 model, 

642 filters=Q(custom_field_data__has_key=old_name), 

643 custom_field_data=Func( 

644 F('custom_field_data') - old_name, 

645 Value([new_name]), 

646 Func( 

647 F('custom_field_data'), 

648 Value(old_name), 

649 function='jsonb_extract_path', 

650 output_field=models.JSONField() 

651 ), 

652 function='jsonb_set') 

653 ) 

654 

655 def delete(self, using=None, *args, **kwargs): 

656 """ 

657 Delete the field, deferring the removal of its stored data to a background job where too 

658 many objects are affected to handle within the request (see #22996). 

659 

660 Where the work is deferred, the row is retained until the job completes: `name` is unique, so 

661 for as long as the row exists no other field can take this name and inherit the data still 

662 awaiting removal. 

663 

664 The deletion signals are dispatched here rather than when the row is finally removed, so that 

665 protection rules, the change log, event rules and the search index observe the deletion where 

666 the user performed it. They run again in the worker, where every effect beyond the protection 

667 rules is gated on there being a current request, making the replay a no-op. 

668 

669 The deletion is refused outright if a background job holds the field's data lock, rather than 

670 queueing behind that job. This applies equally to a field already pending deletion: reporting 

671 a deletion which did not happen would be worse than refusing it. A field stranded in a pending 

672 state by a job which never ran holds no lock, and stays deletable; retrying the deletion of 

673 one already pending enqueues a fresh purge job for it. 

674 

675 Deleting a field already marked for deletion -- by an earlier request of the user's own, or by 

676 a concurrent one -- removes nothing further and dispatches no second set of deletion signals. 

677 """ 

678 from extras.jobs import CustomFieldPurgeJob 

679 

680 using = using or router.db_for_write(self.__class__, instance=self) 

681 

682 with transaction.atomic(using=using): 

683 if not self._try_lock_data(self.pk, using): 

684 raise AbortRequest( 

685 _("Custom field '{name}' is being updated by a background job and cannot be " 

686 "deleted until that job has completed.").format(name=self.name) 

687 ) 

688 

689 # The status is re-read under a row lock rather than taken from this instance 

690 self.status = self._lock_status(using) 

691 if self.status is None: 

692 # Already deleted outright by a concurrent request; nothing remains to delete. 

693 return 0, {} 

694 

695 if self.status == CustomFieldStatusChoices.STATUS_DELETING: 

696 # Already pending deletion; the purge job will remove the row once its data is gone. 

697 # The lock being free, no job is *running*, so the one enqueued when the field was 

698 # marked may never have run: enqueue another, delete() being the only route to one. 

699 # Left as it is, a field whose job never ran could never be removed, and would hold 

700 # its name against a replacement indefinitely. Where that job is merely queued (a 

701 # concurrent deletion having just marked the field), the second job is harmless: 

702 # purge_custom_field() rechecks the status under the lock and no-ops. 

703 transaction.on_commit(lambda: CustomFieldPurgeJob.enqueue_for(self), using=using) 

704 return 0, {} 

705 

706 if not self._exceeds_inline_limit(self.object_types.all()): 

707 # Few enough objects to purge within the request: delete the row outright, its 

708 # stored data being removed by handle_cf_deleted(). 

709 return super().delete(using, *args, **kwargs) 

710 

711 # Update the custom field's status before the signals are dispatched. Applied via the 

712 # queryset to avoid emitting a spurious "updated" change record. 

713 self.status = CustomFieldStatusChoices.STATUS_DELETING 

714 self.__class__.objects.using(using).filter(pk=self.pk).update(status=self.status) 

715 self.__class__.objects.clear_cache() 

716 

717 models.signals.pre_delete.send(sender=self.__class__, instance=self, using=using, origin=self) 

718 models.signals.post_delete.send(sender=self.__class__, instance=self, using=using, origin=self) 

719 

720 # Deferred until commit so that the worker cannot observe the field before it is marked, 

721 # and is not enqueued at all if the deletion is aborted. 

722 transaction.on_commit(lambda: CustomFieldPurgeJob.enqueue_for(self), using=using) 

723 

724 return 1, {self._meta.label: 1} 

725 

726 def _delete_row(self): 

727 """ 

728 Remove the row itself. Called by CustomFieldPurgeJob once the field's stored data has been 

729 purged; nothing else should bypass delete(). 

730 """ 

731 return super().delete() 

732 

733 def clean(self): 

734 super().clean() 

735 

736 # A field awaiting a bulk update of its stored data is not live, and its configuration must 

737 # not change under the job which is acting on it. 

738 if self.pk and self.status != CustomFieldStatusChoices.STATUS_ACTIVE: 

739 raise ValidationError( 

740 _("Custom field '{name}' cannot be modified while its stored data is being updated " 

741 "(status: {status}).").format(name=self.name, status=self.get_status_display().lower()) 

742 ) 

743 

744 # Validate the field's default value (if any) 

745 if self.default is not None: 

746 try: 

747 if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT): 

748 default_value = str(self.default) 

749 else: 

750 default_value = self.default 

751 self.validate(default_value) 

752 except ValidationError as err: 

753 raise ValidationError({ 

754 'default': _( 

755 'Invalid default value "{value}": {error}' 

756 ).format(value=self.default, error=err.message) 

757 }) 

758 

759 # Minimum/maximum values can be set only for numeric fields 

760 if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL): 

761 if self.validation_minimum: 

762 raise ValidationError({'validation_minimum': _("A minimum value may be set only for numeric fields")}) 

763 if self.validation_maximum: 

764 raise ValidationError({'validation_maximum': _("A maximum value may be set only for numeric fields")}) 

765 

766 # Regex validation can be set only for text fields 

767 regex_types = ( 

768 CustomFieldTypeChoices.TYPE_TEXT, 

769 CustomFieldTypeChoices.TYPE_LONGTEXT, 

770 CustomFieldTypeChoices.TYPE_URL, 

771 ) 

772 if self.validation_regex and self.type not in regex_types: 

773 raise ValidationError({ 

774 'validation_regex': _("Regular expression validation is supported only for text and URL fields") 

775 }) 

776 

777 # Schema validation can be set only for JSON fields 

778 if self.validation_schema and self.type != CustomFieldTypeChoices.TYPE_JSON: 

779 raise ValidationError({ 

780 'validation_schema': _("JSON schema validation is supported only for JSON fields") 

781 }) 

782 

783 # Uniqueness can not be enforced for boolean fields 

784 if self.unique and self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: 

785 raise ValidationError({ 

786 'unique': _("Uniqueness cannot be enforced for boolean fields") 

787 }) 

788 

789 # Choice set must be set on selection fields, and *only* on selection fields 

790 if self.type in ( 

791 CustomFieldTypeChoices.TYPE_SELECT, 

792 CustomFieldTypeChoices.TYPE_MULTISELECT 

793 ): 

794 if not self.choice_set: 

795 raise ValidationError({ 

796 'choice_set': _("Selection fields must specify a set of choices.") 

797 }) 

798 elif self.choice_set: 

799 raise ValidationError({ 

800 'choice_set': _("Choices may be set only on selection fields.") 

801 }) 

802 

803 # Object fields must define an object_type; other fields must not 

804 if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT): 

805 if not self.related_object_type: 

806 raise ValidationError({ 

807 'related_object_type': _("Object fields must define an object type.") 

808 }) 

809 elif self.related_object_type: 

810 raise ValidationError({ 

811 'type': _("{type} fields may not define an object type.") .format(type=self.get_type_display()) 

812 }) 

813 

814 # Related object filter can be set only for object-type fields, and must contain a dictionary mapping (if set) 

815 if self.related_object_filter is not None: 

816 if self.type not in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT): 

817 raise ValidationError({ 

818 'related_object_filter': _("A related object filter can be defined only for object fields.") 

819 }) 

820 if type(self.related_object_filter) is not dict: 

821 raise ValidationError({ 

822 'related_object_filter': _("Filter must be defined as a dictionary mapping attributes to values.") 

823 }) 

824 

825 def serialize(self, value): 

826 """ 

827 Prepare a value for storage as JSON data. 

828 """ 

829 if value is None: 

830 return value 

831 if self.type == CustomFieldTypeChoices.TYPE_DECIMAL: 

832 return float(value) 

833 if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date: 

834 return value.isoformat() 

835 if self.type == CustomFieldTypeChoices.TYPE_DATETIME and type(value) is datetime: 

836 return value.isoformat() 

837 if self.type == CustomFieldTypeChoices.TYPE_OBJECT: 

838 return value.pk 

839 if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: 

840 return [obj.pk for obj in value] or None 

841 return value 

842 

843 def deserialize(self, value): 

844 """ 

845 Convert JSON data to a Python object suitable for the field type. 

846 """ 

847 if value is None: 

848 return value 

849 if self.type == CustomFieldTypeChoices.TYPE_DATE: 

850 try: 

851 return date.fromisoformat(value) 

852 except ValueError: 

853 return value 

854 if self.type == CustomFieldTypeChoices.TYPE_DATETIME: 

855 try: 

856 return datetime.fromisoformat(value) 

857 except ValueError: 

858 return value 

859 if self.type == CustomFieldTypeChoices.TYPE_OBJECT: 

860 model = self.related_object_type.model_class() 

861 return model.objects.filter(pk=value).first() 

862 if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: 

863 model = self.related_object_type.model_class() 

864 return model.objects.filter(pk__in=value) 

865 return value 

866 

867 def to_form_field( 

868 self, 

869 set_initial=True, 

870 enforce_required=True, 

871 enforce_visibility=True, 

872 for_csv_import=False, 

873 for_filterset_form=False, 

874 ): 

875 """ 

876 Return a form field suitable for setting a CustomField's value for an object. 

877 

878 set_initial: Set initial data for the field. This should be False when generating a field for bulk editing. 

879 enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. 

880 enforce_visibility: Honor the value of CustomField.ui_visible. Set to False for filtering. 

881 for_csv_import: Return a form field suitable for bulk import of objects in CSV format. 

882 for_filterset_form: Return a form field suitable for use in a FilterSet form. 

883 """ 

884 initial = self.default if set_initial else None 

885 required = self.required if enforce_required else False 

886 

887 # Integer 

888 if self.type == CustomFieldTypeChoices.TYPE_INTEGER: 

889 field = forms.IntegerField( 

890 required=required, 

891 initial=initial, 

892 min_value=self.validation_minimum, 

893 max_value=self.validation_maximum 

894 ) 

895 

896 # Decimal 

897 elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: 

898 field = forms.DecimalField( 

899 required=required, 

900 initial=initial, 

901 max_digits=16, 

902 decimal_places=4, 

903 min_value=self.validation_minimum, 

904 max_value=self.validation_maximum 

905 ) 

906 

907 # Boolean 

908 elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: 

909 choices = ( 

910 (None, '---------'), 

911 (True, _('True')), 

912 (False, _('False')), 

913 ) 

914 field = forms.NullBooleanField( 

915 required=required, initial=initial, widget=forms.Select(choices=choices) 

916 ) 

917 

918 # Date 

919 elif self.type == CustomFieldTypeChoices.TYPE_DATE: 

920 field = forms.DateField(required=required, initial=initial, widget=DatePicker()) 

921 

922 # Date & time 

923 elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: 

924 field = forms.DateTimeField(required=required, initial=initial, widget=DateTimePicker()) 

925 

926 # Select 

927 elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT): 

928 choices = self.choice_set.choices 

929 default_choice = self.default if self.default in self.choices else None 

930 

931 if not required or default_choice is None: 

932 choices = add_blank_choice(choices) 

933 

934 # Set the initial value to the first available choice (if any) 

935 if set_initial and default_choice: 

936 initial = default_choice 

937 

938 if for_csv_import: 

939 if self.type == CustomFieldTypeChoices.TYPE_SELECT: 

940 field_class = CSVChoiceField 

941 else: 

942 field_class = CSVMultipleChoiceField 

943 field = field_class(choices=choices, required=required, initial=initial) 

944 else: 

945 if self.type == CustomFieldTypeChoices.TYPE_SELECT and not for_filterset_form: 

946 field_class = DynamicChoiceField 

947 widget_class = APISelect 

948 else: 

949 field_class = DynamicMultipleChoiceField 

950 widget_class = APISelectMultiple 

951 field = field_class( 

952 choices=choices, 

953 required=required, 

954 initial=initial, 

955 widget=widget_class(api_url=f'/api/extras/custom-field-choice-sets/{self.choice_set.pk}/choices/') 

956 ) 

957 

958 # URL 

959 elif self.type == CustomFieldTypeChoices.TYPE_URL: 

960 field = LaxURLField(assume_scheme='https', required=required, initial=initial) 

961 if self.validation_regex: 

962 field.validators = [ 

963 RegexValidator( 

964 regex=self.validation_regex, 

965 message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format( 

966 regex=escape(self.validation_regex) 

967 )) 

968 ) 

969 ] 

970 

971 # JSON 

972 elif self.type == CustomFieldTypeChoices.TYPE_JSON: 

973 field = JSONField(required=required, initial=json.dumps(initial) if initial is not None else None) 

974 

975 # Object 

976 elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: 

977 model = self.related_object_type.model_class() 

978 if for_csv_import: 

979 field_class = CSVModelChoiceField 

980 elif for_filterset_form: 

981 field_class = DynamicModelMultipleChoiceField 

982 else: 

983 field_class = DynamicModelChoiceField 

984 kwargs = { 

985 'queryset': model.objects.all(), 

986 'required': required, 

987 'initial': initial, 

988 } 

989 if not for_csv_import: 

990 kwargs['query_params'] = self.related_object_filter 

991 kwargs['selector'] = True 

992 

993 field = field_class(**kwargs) 

994 

995 # Multiple objects 

996 elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: 

997 model = self.related_object_type.model_class() 

998 field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField 

999 kwargs = { 

1000 'queryset': model.objects.all(), 

1001 'required': required, 

1002 'initial': initial, 

1003 } 

1004 if not for_csv_import: 

1005 kwargs['query_params'] = self.related_object_filter 

1006 kwargs['selector'] = True 

1007 

1008 field = field_class(**kwargs) 

1009 

1010 # Text 

1011 else: 

1012 widget = forms.Textarea if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT else None 

1013 field = forms.CharField(required=required, initial=initial, widget=widget) 

1014 if self.validation_regex: 

1015 field.validators = [ 

1016 RegexValidator( 

1017 regex=self.validation_regex, 

1018 message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format( 

1019 regex=escape(self.validation_regex) 

1020 )) 

1021 ) 

1022 ] 

1023 

1024 field.model = self 

1025 field.label = str(self) 

1026 if self.description: 

1027 field.help_text = render_markdown(self.description) 

1028 

1029 # Annotate read-only fields 

1030 if enforce_visibility and self.ui_editable != CustomFieldUIEditableChoices.YES: 

1031 field.disabled = True 

1032 

1033 return field 

1034 

1035 def to_filter(self, lookup_expr=None): 

1036 """ 

1037 Return a django_filters Filter instance suitable for this field type. 

1038 

1039 :param lookup_expr: Custom lookup expression (optional) 

1040 """ 

1041 # Imported locally as extras.filters imports extras.models 

1042 from extras.filters import missing_key_aware_filter_factory 

1043 

1044 kwargs = { 

1045 'field_name': f'custom_field_data__{self.name}' 

1046 } 

1047 # Native numeric filters will use `isnull` by default for empty lookups, but 

1048 # JSON fields require `empty` (see bug #20012). 

1049 if lookup_expr == 'isnull': 

1050 lookup_expr = 'empty' 

1051 if lookup_expr is not None: 

1052 kwargs['lookup_expr'] = lookup_expr 

1053 

1054 # 'Empty' lookup is always a boolean 

1055 if lookup_expr == 'empty': 

1056 filter_class = django_filters.BooleanFilter 

1057 

1058 # Text/URL 

1059 elif self.type in ( 

1060 CustomFieldTypeChoices.TYPE_TEXT, 

1061 CustomFieldTypeChoices.TYPE_LONGTEXT, 

1062 CustomFieldTypeChoices.TYPE_URL, 

1063 ): 

1064 filter_class = filters.MultiValueCharFilter 

1065 if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: 

1066 kwargs['lookup_expr'] = 'icontains' 

1067 

1068 # Integer 

1069 elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: 

1070 filter_class = filters.MultiValueNumberFilter 

1071 

1072 # Decimal 

1073 elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: 

1074 filter_class = filters.MultiValueDecimalFilter 

1075 

1076 # Boolean 

1077 elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: 

1078 filter_class = django_filters.BooleanFilter 

1079 

1080 # Date 

1081 elif self.type == CustomFieldTypeChoices.TYPE_DATE: 

1082 filter_class = filters.MultiValueDateFilter 

1083 

1084 # Date & time 

1085 elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: 

1086 filter_class = filters.MultiValueDateTimeFilter 

1087 

1088 # Select 

1089 elif self.type == CustomFieldTypeChoices.TYPE_SELECT: 

1090 filter_class = filters.MultiValueCharFilter 

1091 

1092 # Multiselect 

1093 elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: 

1094 # Do not pin lookup_expr: FILTER_ARRAY_BASED_LOOKUP_MAP preserves the class default under negation 

1095 filter_class = filters.MultiValueArrayFilter 

1096 

1097 # Object 

1098 elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: 

1099 filter_class = filters.MultiValueNumberFilter 

1100 

1101 # Multi-object 

1102 elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: 

1103 filter_class = filters.MultiValueNumberFilter 

1104 kwargs['lookup_expr'] = 'contains' 

1105 

1106 # Unsupported custom field type 

1107 else: 

1108 return None 

1109 

1110 # A negated lookup must match objects which carry no key for this field at all; see 

1111 # MissingKeyAwareFilterMixin. BooleanFilter is never negated, so it is left alone. 

1112 if not issubclass(filter_class, django_filters.BooleanFilter): 

1113 filter_class = missing_key_aware_filter_factory(filter_class) 

1114 

1115 filter_instance = filter_class(**kwargs) 

1116 filter_instance.custom_field = self 

1117 

1118 return filter_instance 

1119 

1120 def validate(self, value): 

1121 """ 

1122 Validate a value according to the field's type validation rules. 

1123 """ 

1124 if value not in [None, '']: 

1125 

1126 # Validate text field 

1127 if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT): 

1128 if type(value) is not str: 

1129 raise ValidationError(_("Value must be a string.")) 

1130 if self.validation_regex and not re.match(self.validation_regex, value): 

1131 raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex)) 

1132 

1133 # Validate URL field 

1134 elif self.type == CustomFieldTypeChoices.TYPE_URL: 

1135 if type(value) is not str: 

1136 raise ValidationError(_("Value must be a string.")) 

1137 # Enforce ALLOWED_URL_SCHEMES to guard against dangerous schemes (e.g. javascript:). A 

1138 # schemeless value is permitted and treated as relative. 

1139 if not url_scheme_is_allowed(value): 

1140 raise ValidationError( 

1141 _("URLs must use a scheme permitted by ALLOWED_URL_SCHEMES.") 

1142 ) 

1143 if self.validation_regex and not re.match(self.validation_regex, value): 

1144 raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex)) 

1145 

1146 # Validate integer 

1147 elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: 

1148 if type(value) is not int: 

1149 raise ValidationError(_("Value must be an integer.")) 

1150 if self.validation_minimum is not None and value < self.validation_minimum: 

1151 raise ValidationError( 

1152 _("Value must be at least {minimum}").format(minimum=self.validation_minimum) 

1153 ) 

1154 if self.validation_maximum is not None and value > self.validation_maximum: 

1155 raise ValidationError( 

1156 _("Value must not exceed {maximum}").format(maximum=self.validation_maximum) 

1157 ) 

1158 

1159 # Validate decimal 

1160 elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: 

1161 try: 

1162 decimal.Decimal(value) 

1163 except decimal.InvalidOperation: 

1164 raise ValidationError(_("Value must be a decimal.")) 

1165 if self.validation_minimum is not None and value < self.validation_minimum: 

1166 raise ValidationError( 

1167 _("Value must be at least {minimum}").format(minimum=self.validation_minimum) 

1168 ) 

1169 if self.validation_maximum is not None and value > self.validation_maximum: 

1170 raise ValidationError( 

1171 _("Value must not exceed {maximum}").format(maximum=self.validation_maximum) 

1172 ) 

1173 

1174 # Validate boolean 

1175 elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: 

1176 raise ValidationError(_("Value must be true or false.")) 

1177 

1178 # Validate date 

1179 elif self.type == CustomFieldTypeChoices.TYPE_DATE: 

1180 if type(value) is not date: 

1181 try: 

1182 date.fromisoformat(value) 

1183 except ValueError: 

1184 raise ValidationError(_("Date values must be in ISO 8601 format (YYYY-MM-DD).")) 

1185 

1186 # Validate date & time 

1187 elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: 

1188 if type(value) is not datetime: 

1189 try: 

1190 datetime_from_timestamp(value) 

1191 except ValueError: 

1192 raise ValidationError( 

1193 _("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).") 

1194 ) 

1195 

1196 # Validate selected choice 

1197 elif self.type == CustomFieldTypeChoices.TYPE_SELECT: 

1198 if value not in self.choice_set.values: 

1199 raise ValidationError( 

1200 _("Invalid choice ({value}) for choice set {choiceset}.").format( 

1201 value=value, 

1202 choiceset=self.choice_set 

1203 ) 

1204 ) 

1205 

1206 # Validate all selected choices 

1207 elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: 

1208 # Require a list of valid string choices. The isinstance() check short-circuits the membership 

1209 # test so that non-string members (e.g. a client echoing back the {value, label} read 

1210 # representation) raise a ValidationError rather than an unhashable-type TypeError. 

1211 valid_values = set(self.choice_set.values) 

1212 if type(value) is not list or not all(isinstance(v, str) and v in valid_values for v in value): 

1213 raise ValidationError( 

1214 _("Invalid choice(s) ({value}) for choice set {choiceset}.").format( 

1215 value=value, 

1216 choiceset=self.choice_set 

1217 ) 

1218 ) 

1219 

1220 # Validate selected object 

1221 elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: 

1222 if type(value) is not int: 

1223 raise ValidationError(_("Value must be an object ID, not {type}").format(type=type(value).__name__)) 

1224 

1225 # Validate selected objects 

1226 elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: 

1227 if type(value) is not list: 

1228 raise ValidationError( 

1229 _("Value must be a list of object IDs, not {type}").format(type=type(value).__name__) 

1230 ) 

1231 for id in value: 

1232 if type(id) is not int: 

1233 raise ValidationError(_("Found invalid object ID: {id}").format(id=id)) 

1234 

1235 # Validate JSON against schema (if defined) 

1236 elif self.type == CustomFieldTypeChoices.TYPE_JSON: 

1237 if self.validation_schema: 

1238 try: 

1239 jsonschema.validate(value, schema=self.validation_schema) 

1240 except JSONValidationError as e: 

1241 raise ValidationError( 

1242 _("Value does not conform to the assigned schema: {error}").format(error=e.message) 

1243 ) 

1244 

1245 elif self.required: 

1246 raise ValidationError(_("Required field cannot be empty.")) 

1247 

1248 

1249class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedModel): 

1250 """ 

1251 Represents a set of choices available for choice and multi-choice custom fields. 

1252 """ 

1253 name = models.CharField( 

1254 max_length=100, 

1255 unique=True 

1256 ) 

1257 description = models.CharField( 

1258 max_length=200, 

1259 blank=True 

1260 ) 

1261 base_choices = models.CharField( 

1262 max_length=50, 

1263 choices=CustomFieldChoiceSetBaseChoices, 

1264 blank=True, 

1265 null=True, 

1266 help_text=_('Base set of predefined choices (optional)') 

1267 ) 

1268 extra_choices = ChoiceSetField( 

1269 blank=True, 

1270 null=True 

1271 ) 

1272 choice_colors = models.JSONField( 

1273 default=dict, 

1274 blank=True, 

1275 ) 

1276 order_alphabetically = models.BooleanField( 

1277 default=False, 

1278 help_text=_('Choices are automatically ordered alphabetically') 

1279 ) 

1280 

1281 clone_fields = ('extra_choices', 'choice_colors', 'order_alphabetically') 

1282 

1283 class Meta: 

1284 ordering = ('name',) 

1285 verbose_name = _('custom field choice set') 

1286 verbose_name_plural = _('custom field choice sets') 

1287 

1288 def __str__(self): 

1289 return self.name 

1290 

1291 def __init__(self, *args, **kwargs): 

1292 super().__init__(*args, **kwargs) 

1293 

1294 # Cache the initial set of choices for comparison under clean() 

1295 self._original_extra_choices = self.__dict__.get('extra_choices') 

1296 

1297 def get_absolute_url(self): 

1298 return reverse('extras:customfieldchoiceset', args=[self.pk]) 

1299 

1300 @property 

1301 def choices(self): 

1302 """ 

1303 Returns a concatenation of the base and extra choices. 

1304 """ 

1305 if not hasattr(self, '_choices'): 

1306 self._choices = [] 

1307 if self.base_choices: 

1308 self._choices.extend(CHOICE_SETS.get(self.base_choices)) 

1309 if self.extra_choices: 

1310 self._choices.extend(self.extra_choices) 

1311 if self.order_alphabetically: 

1312 self._choices = sorted(self._choices, key=lambda x: x[0]) 

1313 return self._choices 

1314 

1315 @property 

1316 def colors(self): 

1317 """ 

1318 Return merged color mappings from the selected base choice set (if it defines colors) 

1319 and any custom color overrides defined on this choice set. 

1320 """ 

1321 if not hasattr(self, '_colors'): 

1322 self._colors = {} 

1323 if self.base_choices: 

1324 base_choice_set = CHOICE_SETS.get(self.base_choices) 

1325 self._colors.update(getattr(base_choice_set, 'colors', {})) 

1326 if self.choice_colors: 

1327 self._colors.update(self.choice_colors) 

1328 return self._colors 

1329 

1330 def get_choice_color(self, value): 

1331 return self.colors.get(value) 

1332 

1333 @property 

1334 def choices_count(self): 

1335 return len(self.choices) 

1336 

1337 @property 

1338 def values(self): 

1339 """ 

1340 Returns an iterator of the valid choice values. 

1341 """ 

1342 return (x[0] for x in self.choices) 

1343 

1344 def clean(self): 

1345 if not self.base_choices and not self.extra_choices: 

1346 raise ValidationError(_("Must define base or extra choices.")) 

1347 

1348 if self.choice_colors is None: 

1349 self.choice_colors = {} 

1350 elif not isinstance(self.choice_colors, dict): 

1351 raise ValidationError({ 

1352 'choice_colors': _('Color mappings must be defined as a JSON object.') 

1353 }) 

1354 

1355 valid_choice_values = set() 

1356 extra_choice_values = set() 

1357 

1358 if self.base_choices: 

1359 valid_choice_values.update(value for value, _ in CHOICE_SETS.get(self.base_choices)) 

1360 

1361 if self.extra_choices: 

1362 for value, _label in self.extra_choices: 

1363 if value in extra_choice_values: 

1364 raise ValidationError(_("Duplicate value '{value}' found in extra choices.").format(value=value)) 

1365 extra_choice_values.add(value) 

1366 valid_choice_values.update(extra_choice_values) 

1367 

1368 invalid_choice_values = set() 

1369 invalid_colors = set() 

1370 valid_colors = set(CustomFieldChoiceColorChoices.values()) 

1371 

1372 for value, color in self.choice_colors.items(): 

1373 if value not in valid_choice_values: 

1374 invalid_choice_values.add(value) 

1375 if color not in valid_colors: 

1376 invalid_colors.add(color) 

1377 

1378 if invalid_choice_values: 

1379 raise ValidationError({ 

1380 'choice_colors': _( 

1381 'Color mappings must reference an existing choice value. Invalid value(s): {values}.' 

1382 ).format(values=', '.join(sorted(invalid_choice_values))) 

1383 }) 

1384 

1385 if invalid_colors: 

1386 raise ValidationError({ 

1387 'choice_colors': _( 

1388 'Invalid color value(s): {colors}. Use a supported named color.' 

1389 ).format(colors=', '.join(sorted(invalid_colors))) 

1390 }) 

1391 

1392 # Check whether any choices have been removed. If so, check whether any of the removed 

1393 # choices are still set in custom field data for any object. 

1394 original_choices = set([ 

1395 c[0] for c in self._original_extra_choices 

1396 ]) if self._original_extra_choices else set() 

1397 if removed_choices := original_choices - valid_choice_values: 

1398 for custom_field in self.choices_for.all(): 

1399 for object_type in custom_field.object_types.all(): 

1400 model = object_type.model_class() 

1401 for choice in removed_choices: 

1402 # Form the query based on the type of custom field 

1403 if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT: 

1404 query_args = {f"custom_field_data__{custom_field.name}__contains": choice} 

1405 else: 

1406 query_args = {f"custom_field_data__{custom_field.name}": choice} 

1407 # Raise a ValidationError if there are any objects which still reference the removed choice 

1408 if model.objects.filter(models.Q(**query_args)).exists(): 

1409 raise ValidationError( 

1410 _( 

1411 "Cannot remove choice {choice} as there are {model} objects which reference it." 

1412 ).format(choice=choice, model=object_type) 

1413 ) 

1414 

1415 def save(self, *args, **kwargs): 

1416 

1417 # Sort choices if alphabetical ordering is enforced 

1418 if self.order_alphabetically and self.extra_choices: 

1419 self.extra_choices = sorted(self.extra_choices, key=lambda x: x[0]) 

1420 

1421 return super().save(*args, **kwargs)