customfields.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import re
  2. from collections import OrderedDict
  3. from datetime import datetime, date
  4. from django import forms
  5. from django.contrib.contenttypes.models import ContentType
  6. from django.contrib.postgres.fields import ArrayField
  7. from django.core.serializers.json import DjangoJSONEncoder
  8. from django.core.validators import RegexValidator, ValidationError
  9. from django.db import models
  10. from django.utils.safestring import mark_safe
  11. from extras.choices import *
  12. from extras.utils import FeatureQuery
  13. from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
  14. from utilities.querysets import RestrictedQuerySet
  15. from utilities.validators import validate_regex
  16. class CustomFieldModel(models.Model):
  17. """
  18. Abstract class for any model which may have custom fields associated with it.
  19. """
  20. custom_field_data = models.JSONField(
  21. encoder=DjangoJSONEncoder,
  22. blank=True,
  23. default=dict
  24. )
  25. class Meta:
  26. abstract = True
  27. @property
  28. def cf(self):
  29. """
  30. Convenience wrapper for custom field data.
  31. """
  32. return self.custom_field_data
  33. def get_custom_fields(self):
  34. """
  35. Return a dictionary of custom fields for a single object in the form {<field>: value}.
  36. """
  37. fields = CustomField.objects.get_for_model(self)
  38. return OrderedDict([
  39. (field, self.custom_field_data.get(field.name)) for field in fields
  40. ])
  41. def clean(self):
  42. super().clean()
  43. custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)}
  44. # Validate all field values
  45. for field_name, value in self.custom_field_data.items():
  46. if field_name not in custom_fields:
  47. raise ValidationError(f"Unknown field name '{field_name}' in custom field data.")
  48. try:
  49. custom_fields[field_name].validate(value)
  50. except ValidationError as e:
  51. raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}")
  52. # Check for missing required values
  53. for cf in custom_fields.values():
  54. if cf.required and cf.name not in self.custom_field_data:
  55. raise ValidationError(f"Missing required custom field '{cf.name}'.")
  56. class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
  57. use_in_migrations = True
  58. def get_for_model(self, model):
  59. """
  60. Return all CustomFields assigned to the given model.
  61. """
  62. content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
  63. return self.get_queryset().filter(content_types=content_type)
  64. class CustomField(models.Model):
  65. content_types = models.ManyToManyField(
  66. to=ContentType,
  67. related_name='custom_fields',
  68. verbose_name='Object(s)',
  69. limit_choices_to=FeatureQuery('custom_fields'),
  70. help_text='The object(s) to which this field applies.'
  71. )
  72. type = models.CharField(
  73. max_length=50,
  74. choices=CustomFieldTypeChoices,
  75. default=CustomFieldTypeChoices.TYPE_TEXT
  76. )
  77. name = models.CharField(
  78. max_length=50,
  79. unique=True,
  80. help_text='Internal field name'
  81. )
  82. label = models.CharField(
  83. max_length=50,
  84. blank=True,
  85. help_text='Name of the field as displayed to users (if not provided, '
  86. 'the field\'s name will be used)'
  87. )
  88. description = models.CharField(
  89. max_length=200,
  90. blank=True
  91. )
  92. required = models.BooleanField(
  93. default=False,
  94. help_text='If true, this field is required when creating new objects '
  95. 'or editing an existing object.'
  96. )
  97. filter_logic = models.CharField(
  98. max_length=50,
  99. choices=CustomFieldFilterLogicChoices,
  100. default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
  101. help_text='Loose matches any instance of a given string; exact '
  102. 'matches the entire field.'
  103. )
  104. default = models.JSONField(
  105. blank=True,
  106. null=True,
  107. help_text='Default value for the field (must be a JSON value). Encapsulate '
  108. 'strings with double quotes (e.g. "Foo").'
  109. )
  110. weight = models.PositiveSmallIntegerField(
  111. default=100,
  112. help_text='Fields with higher weights appear lower in a form.'
  113. )
  114. validation_minimum = models.PositiveIntegerField(
  115. blank=True,
  116. null=True,
  117. verbose_name='Minimum value',
  118. help_text='Minimum allowed value (for numeric fields)'
  119. )
  120. validation_maximum = models.PositiveIntegerField(
  121. blank=True,
  122. null=True,
  123. verbose_name='Maximum value',
  124. help_text='Maximum allowed value (for numeric fields)'
  125. )
  126. validation_regex = models.CharField(
  127. blank=True,
  128. validators=[validate_regex],
  129. max_length=500,
  130. verbose_name='Validation regex',
  131. help_text='Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. '
  132. 'For example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
  133. )
  134. choices = ArrayField(
  135. base_field=models.CharField(max_length=100),
  136. blank=True,
  137. null=True,
  138. help_text='Comma-separated list of available choices (for selection fields)'
  139. )
  140. objects = CustomFieldManager()
  141. class Meta:
  142. ordering = ['weight', 'name']
  143. def __str__(self):
  144. return self.label or self.name.replace('_', ' ').capitalize()
  145. def remove_stale_data(self, content_types):
  146. """
  147. Delete custom field data which is no longer relevant (either because the CustomField is
  148. no longer assigned to a model, or because it has been deleted).
  149. """
  150. for ct in content_types:
  151. model = ct.model_class()
  152. for obj in model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False}):
  153. del(obj.custom_field_data[self.name])
  154. obj.save()
  155. def clean(self):
  156. super().clean()
  157. # Validate the field's default value (if any)
  158. if self.default is not None:
  159. try:
  160. self.validate(self.default)
  161. except ValidationError as err:
  162. raise ValidationError({
  163. 'default': f'Invalid default value "{self.default}": {err.message}'
  164. })
  165. # Minimum/maximum values can be set only for numeric fields
  166. if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
  167. raise ValidationError({
  168. 'validation_minimum': "A minimum value may be set only for numeric fields"
  169. })
  170. if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
  171. raise ValidationError({
  172. 'validation_maximum': "A maximum value may be set only for numeric fields"
  173. })
  174. # Regex validation can be set only for text fields
  175. if self.validation_regex and self.type != CustomFieldTypeChoices.TYPE_TEXT:
  176. raise ValidationError({
  177. 'validation_regex': "Regular expression validation is supported only for text and URL fields"
  178. })
  179. # Choices can be set only on selection fields
  180. if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT:
  181. raise ValidationError({
  182. 'choices': "Choices may be set only for custom selection fields."
  183. })
  184. # A selection field must have at least two choices defined
  185. if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2:
  186. raise ValidationError({
  187. 'choices': "Selection fields must specify at least two choices."
  188. })
  189. # A selection field's default (if any) must be present in its available choices
  190. if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
  191. raise ValidationError({
  192. 'default': f"The specified default value ({self.default}) is not listed as an available choice."
  193. })
  194. def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
  195. """
  196. Return a form field suitable for setting a CustomField's value for an object.
  197. set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
  198. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
  199. for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
  200. """
  201. initial = self.default if set_initial else None
  202. required = self.required if enforce_required else False
  203. # Integer
  204. if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  205. field = forms.IntegerField(
  206. required=required,
  207. initial=initial,
  208. min_value=self.validation_minimum,
  209. max_value=self.validation_maximum
  210. )
  211. # Boolean
  212. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  213. choices = (
  214. (None, '---------'),
  215. (True, 'True'),
  216. (False, 'False'),
  217. )
  218. field = forms.NullBooleanField(
  219. required=required, initial=initial, widget=StaticSelect2(choices=choices)
  220. )
  221. # Date
  222. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  223. field = forms.DateField(required=required, initial=initial, widget=DatePicker())
  224. # Select
  225. elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
  226. choices = [(c, c) for c in self.choices]
  227. default_choice = self.default if self.default in self.choices else None
  228. if not required or default_choice is None:
  229. choices = add_blank_choice(choices)
  230. # Set the initial value to the first available choice (if any)
  231. if set_initial and default_choice:
  232. initial = default_choice
  233. field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
  234. field = field_class(
  235. choices=choices, required=required, initial=initial, widget=StaticSelect2()
  236. )
  237. # URL
  238. elif self.type == CustomFieldTypeChoices.TYPE_URL:
  239. field = LaxURLField(required=required, initial=initial)
  240. # Text
  241. else:
  242. field = forms.CharField(max_length=255, required=required, initial=initial)
  243. if self.validation_regex:
  244. field.validators = [
  245. RegexValidator(
  246. regex=self.validation_regex,
  247. message=mark_safe(f"Values must match this regex: <code>{self.validation_regex}</code>")
  248. )
  249. ]
  250. field.model = self
  251. field.label = str(self)
  252. if self.description:
  253. field.help_text = self.description
  254. return field
  255. def validate(self, value):
  256. """
  257. Validate a value according to the field's type validation rules.
  258. """
  259. if value not in [None, '']:
  260. # Validate text field
  261. if self.type == CustomFieldTypeChoices.TYPE_TEXT and self.validation_regex:
  262. if not re.match(self.validation_regex, value):
  263. raise ValidationError(f"Value must match regex '{self.validation_regex}'")
  264. # Validate integer
  265. if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  266. try:
  267. int(value)
  268. except ValueError:
  269. raise ValidationError("Value must be an integer.")
  270. if self.validation_minimum is not None and value < self.validation_minimum:
  271. raise ValidationError(f"Value must be at least {self.validation_minimum}")
  272. if self.validation_maximum is not None and value > self.validation_maximum:
  273. raise ValidationError(f"Value must not exceed {self.validation_maximum}")
  274. # Validate boolean
  275. if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
  276. raise ValidationError("Value must be true or false.")
  277. # Validate date
  278. if self.type == CustomFieldTypeChoices.TYPE_DATE:
  279. if type(value) is not date:
  280. try:
  281. datetime.strptime(value, '%Y-%m-%d')
  282. except ValueError:
  283. raise ValidationError("Date values must be in the format YYYY-MM-DD.")
  284. # Validate selected choice
  285. if self.type == CustomFieldTypeChoices.TYPE_SELECT:
  286. if value not in self.choices:
  287. raise ValidationError(
  288. f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
  289. )
  290. elif self.required:
  291. raise ValidationError("Required field cannot be empty.")