customfields.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. from collections import OrderedDict
  2. from django import forms
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.contrib.postgres.fields import ArrayField
  5. from django.core.serializers.json import DjangoJSONEncoder
  6. from django.core.validators import ValidationError
  7. from django.db import models
  8. from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
  9. from extras.choices import *
  10. from extras.utils import FeatureQuery
  11. class CustomFieldModel(models.Model):
  12. """
  13. Abstract class for any model which may have custom fields associated with it.
  14. """
  15. custom_field_data = models.JSONField(
  16. encoder=DjangoJSONEncoder,
  17. blank=True,
  18. default=dict
  19. )
  20. class Meta:
  21. abstract = True
  22. @property
  23. def cf(self):
  24. """
  25. Convenience wrapper for custom field data.
  26. """
  27. return self.custom_field_data
  28. def get_custom_fields(self):
  29. """
  30. Return a dictionary of custom fields for a single object in the form {<field>: value}.
  31. """
  32. fields = CustomField.objects.get_for_model(self)
  33. return OrderedDict([
  34. (field, self.custom_field_data.get(field.name)) for field in fields
  35. ])
  36. class CustomFieldManager(models.Manager):
  37. use_in_migrations = True
  38. def get_for_model(self, model):
  39. """
  40. Return all CustomFields assigned to the given model.
  41. """
  42. content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
  43. return self.get_queryset().filter(obj_type=content_type)
  44. class CustomField(models.Model):
  45. obj_type = models.ManyToManyField(
  46. to=ContentType,
  47. related_name='custom_fields',
  48. verbose_name='Object(s)',
  49. limit_choices_to=FeatureQuery('custom_fields'),
  50. help_text='The object(s) to which this field applies.'
  51. )
  52. type = models.CharField(
  53. max_length=50,
  54. choices=CustomFieldTypeChoices,
  55. default=CustomFieldTypeChoices.TYPE_TEXT
  56. )
  57. name = models.CharField(
  58. max_length=50,
  59. unique=True
  60. )
  61. label = models.CharField(
  62. max_length=50,
  63. blank=True,
  64. help_text='Name of the field as displayed to users (if not provided, '
  65. 'the field\'s name will be used)'
  66. )
  67. description = models.CharField(
  68. max_length=200,
  69. blank=True
  70. )
  71. required = models.BooleanField(
  72. default=False,
  73. help_text='If true, this field is required when creating new objects '
  74. 'or editing an existing object.'
  75. )
  76. filter_logic = models.CharField(
  77. max_length=50,
  78. choices=CustomFieldFilterLogicChoices,
  79. default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
  80. help_text='Loose matches any instance of a given string; exact '
  81. 'matches the entire field.'
  82. )
  83. default = models.CharField(
  84. max_length=100,
  85. blank=True,
  86. help_text='Default value for the field. Use "true" or "false" for booleans.'
  87. )
  88. weight = models.PositiveSmallIntegerField(
  89. default=100,
  90. help_text='Fields with higher weights appear lower in a form.'
  91. )
  92. choices = ArrayField(
  93. base_field=models.CharField(max_length=100),
  94. blank=True,
  95. null=True,
  96. help_text='Comma-separated list of available choices (for selection fields)'
  97. )
  98. objects = CustomFieldManager()
  99. class Meta:
  100. ordering = ['weight', 'name']
  101. def __str__(self):
  102. return self.label or self.name.replace('_', ' ').capitalize()
  103. def remove_stale_data(self, content_types):
  104. """
  105. Delete custom field data which is no longer relevant (either because the CustomField is
  106. no longer assigned to a model, or because it has been deleted).
  107. """
  108. for ct in content_types:
  109. model = ct.model_class()
  110. for obj in model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False}):
  111. del(obj.custom_field_data[self.name])
  112. obj.save()
  113. def clean(self):
  114. # Choices can be set only on selection fields
  115. if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT:
  116. raise ValidationError({
  117. 'choices': "Choices may be set only for selection-type custom fields."
  118. })
  119. # A selection field's default (if any) must be present in its available choices
  120. if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
  121. raise ValidationError({
  122. 'default': f"The specified default value ({self.default}) is not listed as an available choice."
  123. })
  124. def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
  125. """
  126. Return a form field suitable for setting a CustomField's value for an object.
  127. set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
  128. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
  129. for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
  130. """
  131. initial = self.default if set_initial else None
  132. required = self.required if enforce_required else False
  133. # Integer
  134. if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  135. field = forms.IntegerField(required=required, initial=initial)
  136. # Boolean
  137. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  138. choices = (
  139. (None, '---------'),
  140. (True, 'True'),
  141. (False, 'False'),
  142. )
  143. if initial is not None:
  144. initial = bool(initial)
  145. field = forms.NullBooleanField(
  146. required=required, initial=initial, widget=StaticSelect2(choices=choices)
  147. )
  148. # Date
  149. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  150. field = forms.DateField(required=required, initial=initial, widget=DatePicker())
  151. # Select
  152. elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
  153. choices = [(c, c) for c in self.choices]
  154. if not required:
  155. choices = add_blank_choice(choices)
  156. # Set the initial value to the first available choice (if any)
  157. if set_initial and self.choices:
  158. initial = self.choices[0]
  159. field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
  160. field = field_class(
  161. choices=choices, required=required, initial=initial, widget=StaticSelect2()
  162. )
  163. # URL
  164. elif self.type == CustomFieldTypeChoices.TYPE_URL:
  165. field = LaxURLField(required=required, initial=initial)
  166. # Text
  167. else:
  168. field = forms.CharField(max_length=255, required=required, initial=initial)
  169. field.model = self
  170. field.label = str(self)
  171. if self.description:
  172. field.help_text = self.description
  173. return field