2
0

customfields.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import logging
  2. from collections import OrderedDict
  3. from datetime import date
  4. from django import forms
  5. from django.contrib.contenttypes.fields import GenericForeignKey
  6. from django.contrib.contenttypes.models import ContentType
  7. from django.core.validators import ValidationError
  8. from django.db import models
  9. from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
  10. from extras.choices import *
  11. from extras.utils import FeatureQuery
  12. #
  13. # Custom fields
  14. #
  15. class CustomFieldModel(models.Model):
  16. _cf = None
  17. class Meta:
  18. abstract = True
  19. def cache_custom_fields(self):
  20. """
  21. Cache all custom field values for this instance
  22. """
  23. self._cf = {
  24. field.name: value for field, value in self.get_custom_fields().items()
  25. }
  26. @property
  27. def cf(self):
  28. """
  29. Name-based CustomFieldValue accessor for use in templates
  30. """
  31. if self._cf is None:
  32. self.cache_custom_fields()
  33. return self._cf
  34. def get_custom_fields(self):
  35. """
  36. Return a dictionary of custom fields for a single object in the form {<field>: value}.
  37. """
  38. fields = CustomField.objects.get_for_model(self)
  39. # If the object exists, populate its custom fields with values
  40. if hasattr(self, 'pk'):
  41. values = self.custom_field_values.all()
  42. values_dict = {cfv.field_id: cfv.value for cfv in values}
  43. return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
  44. else:
  45. return OrderedDict([(field, None) for field in fields])
  46. class CustomFieldManager(models.Manager):
  47. use_in_migrations = True
  48. def get_for_model(self, model):
  49. """
  50. Return all CustomFields assigned to the given model.
  51. """
  52. content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
  53. return self.get_queryset().filter(obj_type=content_type)
  54. class CustomField(models.Model):
  55. obj_type = models.ManyToManyField(
  56. to=ContentType,
  57. related_name='custom_fields',
  58. verbose_name='Object(s)',
  59. limit_choices_to=FeatureQuery('custom_fields'),
  60. help_text='The object(s) to which this field applies.'
  61. )
  62. type = models.CharField(
  63. max_length=50,
  64. choices=CustomFieldTypeChoices,
  65. default=CustomFieldTypeChoices.TYPE_TEXT
  66. )
  67. name = models.CharField(
  68. max_length=50,
  69. unique=True
  70. )
  71. label = models.CharField(
  72. max_length=50,
  73. blank=True,
  74. help_text='Name of the field as displayed to users (if not provided, '
  75. 'the field\'s name will be used)'
  76. )
  77. description = models.CharField(
  78. max_length=200,
  79. blank=True
  80. )
  81. required = models.BooleanField(
  82. default=False,
  83. help_text='If true, this field is required when creating new objects '
  84. 'or editing an existing object.'
  85. )
  86. filter_logic = models.CharField(
  87. max_length=50,
  88. choices=CustomFieldFilterLogicChoices,
  89. default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
  90. help_text='Loose matches any instance of a given string; exact '
  91. 'matches the entire field.'
  92. )
  93. default = models.CharField(
  94. max_length=100,
  95. blank=True,
  96. help_text='Default value for the field. Use "true" or "false" for booleans.'
  97. )
  98. weight = models.PositiveSmallIntegerField(
  99. default=100,
  100. help_text='Fields with higher weights appear lower in a form.'
  101. )
  102. objects = CustomFieldManager()
  103. class Meta:
  104. ordering = ['weight', 'name']
  105. def __str__(self):
  106. return self.label or self.name.replace('_', ' ').capitalize()
  107. def serialize_value(self, value):
  108. """
  109. Serialize the given value to a string suitable for storage as a CustomFieldValue
  110. """
  111. if value is None:
  112. return ''
  113. if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  114. return str(int(bool(value)))
  115. if self.type == CustomFieldTypeChoices.TYPE_DATE:
  116. # Could be date/datetime object or string
  117. try:
  118. return value.strftime('%Y-%m-%d')
  119. except AttributeError:
  120. return value
  121. if self.type == CustomFieldTypeChoices.TYPE_SELECT:
  122. # Could be ModelChoiceField or TypedChoiceField
  123. return str(value.id) if hasattr(value, 'id') else str(value)
  124. return value
  125. def deserialize_value(self, serialized_value):
  126. """
  127. Convert a string into the object it represents depending on the type of field
  128. """
  129. if serialized_value == '':
  130. return None
  131. if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  132. return int(serialized_value)
  133. if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  134. return bool(int(serialized_value))
  135. if self.type == CustomFieldTypeChoices.TYPE_DATE:
  136. # Read date as YYYY-MM-DD
  137. return date(*[int(n) for n in serialized_value.split('-')])
  138. if self.type == CustomFieldTypeChoices.TYPE_SELECT:
  139. return self.choices.get(pk=int(serialized_value))
  140. return serialized_value
  141. def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
  142. """
  143. Return a form field suitable for setting a CustomField's value for an object.
  144. set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
  145. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
  146. for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
  147. """
  148. initial = self.default if set_initial else None
  149. required = self.required if enforce_required else False
  150. # Integer
  151. if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  152. field = forms.IntegerField(required=required, initial=initial)
  153. # Boolean
  154. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  155. choices = (
  156. (None, '---------'),
  157. (1, 'True'),
  158. (0, 'False'),
  159. )
  160. if initial is not None and initial.lower() in ['true', 'yes', '1']:
  161. initial = 1
  162. elif initial is not None and initial.lower() in ['false', 'no', '0']:
  163. initial = 0
  164. else:
  165. initial = None
  166. field = forms.NullBooleanField(
  167. required=required, initial=initial, widget=StaticSelect2(choices=choices)
  168. )
  169. # Date
  170. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  171. field = forms.DateField(required=required, initial=initial, widget=DatePicker())
  172. # Select
  173. elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
  174. choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
  175. if not required:
  176. choices = add_blank_choice(choices)
  177. # Set the initial value to the PK of the default choice, if any
  178. if set_initial:
  179. default_choice = self.choices.filter(value=self.default).first()
  180. if default_choice:
  181. initial = default_choice.pk
  182. field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
  183. field = field_class(
  184. choices=choices, required=required, initial=initial, widget=StaticSelect2()
  185. )
  186. # URL
  187. elif self.type == CustomFieldTypeChoices.TYPE_URL:
  188. field = LaxURLField(required=required, initial=initial)
  189. # Text
  190. else:
  191. field = forms.CharField(max_length=255, required=required, initial=initial)
  192. field.model = self
  193. field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
  194. if self.description:
  195. field.help_text = self.description
  196. return field
  197. class CustomFieldValue(models.Model):
  198. field = models.ForeignKey(
  199. to='extras.CustomField',
  200. on_delete=models.CASCADE,
  201. related_name='values'
  202. )
  203. obj_type = models.ForeignKey(
  204. to=ContentType,
  205. on_delete=models.PROTECT,
  206. related_name='+'
  207. )
  208. obj_id = models.PositiveIntegerField()
  209. obj = GenericForeignKey(
  210. ct_field='obj_type',
  211. fk_field='obj_id'
  212. )
  213. serialized_value = models.CharField(
  214. max_length=255
  215. )
  216. class Meta:
  217. ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique
  218. unique_together = ('field', 'obj_type', 'obj_id')
  219. def __str__(self):
  220. return '{} {}'.format(self.obj, self.field)
  221. @property
  222. def value(self):
  223. return self.field.deserialize_value(self.serialized_value)
  224. @value.setter
  225. def value(self, value):
  226. self.serialized_value = self.field.serialize_value(value)
  227. def save(self, *args, **kwargs):
  228. # Delete this object if it no longer has a value to store
  229. if self.pk and self.value is None:
  230. self.delete()
  231. else:
  232. super().save(*args, **kwargs)
  233. class CustomFieldChoice(models.Model):
  234. field = models.ForeignKey(
  235. to='extras.CustomField',
  236. on_delete=models.CASCADE,
  237. related_name='choices',
  238. limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
  239. )
  240. value = models.CharField(
  241. max_length=100
  242. )
  243. weight = models.PositiveSmallIntegerField(
  244. default=100,
  245. help_text='Higher weights appear lower in the list'
  246. )
  247. class Meta:
  248. ordering = ['field', 'weight', 'value']
  249. unique_together = ['field', 'value']
  250. def __str__(self):
  251. return self.value
  252. def clean(self):
  253. if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
  254. raise ValidationError("Custom field choices can only be assigned to selection fields.")
  255. def delete(self, using=None, keep_parents=False):
  256. # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
  257. pk = self.pk
  258. super().delete(using, keep_parents)
  259. CustomFieldValue.objects.filter(
  260. field__type=CustomFieldTypeChoices.TYPE_SELECT,
  261. serialized_value=str(pk)
  262. ).delete()