Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# (c) 2018-2020
2# MPIB <https://www.mpib-berlin.mpg.de/>,
3# MPI-CBS <https://www.cbs.mpg.de/>,
4# MPIP <http://www.psych.mpg.de/>
5#
6# This file is part of Castellum.
7#
8# Castellum is free software; you can redistribute it and/or modify it
9# under the terms of the GNU Affero General Public License as published
10# by the Free Software Foundation; either version 3 of the License, or
11# (at your option) any later version.
12#
13# Castellum is distributed in the hope that it will be useful, but
14# WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16# Affero General Public License for more details.
17#
18# You should have received a copy of the GNU Affero General Public
19# License along with Castellum. If not, see
20# <http://www.gnu.org/licenses/>.
22import datetime
24from django import forms
25from django.db import models
26from django.utils.translation import gettext_lazy as _
28from dateutil.relativedelta import relativedelta
30from castellum.utils.forms import DateField
31from castellum.utils.forms import IntegerChoiceField
32from castellum.utils.forms import IntegerMultipleChoiceField
34from .fields import AgeField
36ANSWER_DECLINED = "ANSWER_DECLINED"
39class BaseAttributeField:
40 available_operators = NotImplemented
41 form_class = NotImplemented
42 filter_form_class = None
43 statistics_buckets = []
45 def __init__(self, description):
46 self.description = description
48 def formfield(self, form_class=None, **kwargs):
49 form_class = form_class or self.form_class
50 defaults = {
51 'label': self.description.label,
52 'help_text': self.description.help_text,
53 'required': False,
54 }
55 defaults.update(kwargs)
56 return form_class(**defaults)
58 def filter_formfield(self, **kwargs):
59 return self.formfield(self.filter_form_class, **kwargs)
61 def _filter_to_q(self, operator, value):
62 field_name = 'attributeset__data__' + self.description.json_key
63 key = '{}__{}'.format(field_name, operator)
64 return models.Q(**{key: value})
66 def filter_to_q(self, operator, value, include_unknown=True):
67 field_name = 'attributeset__data__' + self.description.json_key
68 inverse = operator.startswith('!')
69 if inverse: 69 ↛ 70line 69 didn't jump to line 70, because the condition on line 69 was never true
70 operator = operator[1:]
71 q = self._filter_to_q(operator, value)
72 q_unknown = (
73 models.Q(**{field_name: ''}) |
74 models.Q(**{field_name: None}) |
75 models.Q(**{field_name + '__isnull': True})
76 )
78 if inverse: 78 ↛ 79line 78 didn't jump to line 79, because the condition on line 78 was never true
79 if include_unknown:
80 return ~q
81 else:
82 return ~q & ~q_unknown
83 else:
84 if include_unknown: 84 ↛ 87line 84 didn't jump to line 87, because the condition on line 84 was never false
85 return q | q_unknown
86 else:
87 return q
89 def from_json(self, value):
90 return value
92 def get_display(self, value):
93 if value is None:
94 return '—'
95 elif value is ANSWER_DECLINED:
96 return _('Declined to answer')
97 else:
98 return self.from_json(value)
100 def get_filter_display(self, operator, value):
101 operator = dict(self.available_operators)[operator]
102 return '{} {} {}'.format(self.description.filter_label, operator, value)
104 def get_statistics_buckets(self):
105 return self.statistics_buckets
107 def get_statistics_bucket(self, value):
108 return value
111class DateAttributeField(BaseAttributeField):
112 form_class = DateField
113 available_operators = [
114 ("lt", _("before")),
115 ("gt", _("after")),
116 ]
118 def from_json(self, value):
119 if value in [None, ANSWER_DECLINED]:
120 return value
121 dt = datetime.datetime.strptime(value, '%Y-%m-%d')
122 return dt.date()
125class NumberAttributeField(BaseAttributeField):
126 form_class = forms.IntegerField
127 available_operators = [
128 ("lt", _("<")),
129 ("gt", _(">")),
130 ("exact", _("==")),
131 ("!exact", _("!=")),
132 ("lte", _("<=")),
133 ("gte", _(">=")),
134 ]
137class BooleanAttributeField(BaseAttributeField):
138 form_class = forms.NullBooleanField
139 available_operators = [
140 ("exact", _("is")),
141 ]
142 statistics_buckets = [
143 (True, _('Yes')),
144 (False, _('No')),
145 ]
148class TextAttributeField(BaseAttributeField):
149 form_class = forms.CharField
150 available_operators = [
151 ("exact", _("is")),
152 ("!exact", _("is not")),
153 ("icontains", _("contains")),
154 ]
157class ChoiceAttributeField(BaseAttributeField):
158 form_class = IntegerChoiceField
159 available_operators = [
160 ("exact", _("is")),
161 ("!exact", _("is not")),
162 ]
164 def formfield(self, form_class=None, **kwargs):
165 qs = self.description.attributechoice_set.translated()
166 choices = [(None, _('Unknown'))] + list(qs.values_list('pk', 'translations__label'))
167 return super().formfield(form_class=form_class, choices=choices, **kwargs)
169 def get_display(self, value):
170 if value in [None, ANSWER_DECLINED]:
171 return super().get_display(value)
172 choice = self.description.attributechoice_set.get(pk=value)
173 return choice.label
175 def get_filter_display(self, operator, value):
176 value = self.get_display(value)
177 return super().get_filter_display(operator, value)
179 def get_statistics_buckets(self):
180 return [
181 (choice.pk, choice.label) for choice in self.description.attributechoice_set.all()
182 ]
185class MultipleChoiceAttributeField(ChoiceAttributeField):
186 form_class = IntegerMultipleChoiceField
187 filter_form_class = IntegerChoiceField
188 available_operators = [
189 ("contains", _("contains")),
190 ("!contains", _("does not contain")),
191 ]
193 def get_display(self, value):
194 if not value:
195 return super().get_display(None)
196 if value == ANSWER_DECLINED:
197 return super().get_display(value)
198 if not isinstance(value, list):
199 value = [value]
200 s = super()
201 return ', '.join(s.get_display(v) for v in value)
204class OrderedChoiceAttributeField(ChoiceAttributeField):
205 available_operators = ChoiceAttributeField.available_operators + [
206 ("gte", _("is at least")),
207 ("lte", _("is at most")),
208 ]
210 def _filter_to_q(self, operator, value):
211 if operator in ['gte', 'lte']:
212 threshold = self.description.attributechoice_set.get(pk=value)
213 allowed_choices = self.description.attributechoice_set.filter(**{
214 'order__' + operator: threshold.order
215 })
216 # JSONField cannot use `in` lookups
217 q = models.Q()
218 for choice in allowed_choices:
219 q |= models.Q(**{'attributeset__data__' + self.description.json_key: choice.pk})
220 return q
221 return super()._filter_to_q(operator, value)
224class AgeAttributeField(DateAttributeField):
225 SCALES = {
226 'years': _('years'),
227 'months': _('months'),
228 'days': _('days'),
229 }
231 filter_form_class = AgeField
232 available_operators = [
233 ("lt", _("older than")),
234 ("gt", _("younger than")),
235 ]
236 statistics_buckets = [
237 ('<18', '<18'),
238 ('18-25', '18-25'),
239 ('26-30', '26-30'),
240 ('31-35', '31-35'),
241 ('36-40', '36-40'),
242 ('41-45', '41-45'),
243 ('46-50', '46-50'),
244 ('51-55', '51-55'),
245 ('56-60', '56-60'),
246 ('61-65', '61-65'),
247 ('66-70', '66-70'),
248 ('>70', '>70'),
249 ]
251 def _filter_to_q(self, operator, value):
252 period, scale = value
253 try:
254 date = datetime.date.today() - relativedelta(**{scale: period})
255 except (ValueError, OverflowError):
256 date = datetime.date.min
257 return super()._filter_to_q(operator, date)
259 def get_filter_display(self, operator, value):
260 period, scale = value
261 value = '{} {}'.format(period, self.SCALES[scale])
262 return super().get_filter_display(operator, value)
264 def get_statistics_bucket(self, value):
265 today = datetime.date.today()
266 if today < value + relativedelta(years=18):
267 return '<18'
268 if today < value + relativedelta(years=25):
269 return '18-25'
270 for y in range(26, 70, 5):
271 if today < value + relativedelta(years=y + 5):
272 return '{}-{}'.format(y, y + 4)
273 return '>70'