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.conf import settings
25from django.db import models
26from django.utils import timezone
27from django.utils.functional import cached_property
28from django.utils.translation import gettext_lazy as _
30from dateutil.relativedelta import relativedelta
32from castellum.utils.fields import DateTimeField
33from castellum.utils.fields import RestrictedFileField
34from castellum.utils.models import TimeStampedModel
37class TimeSlot(models.Model):
38 hour = models.PositiveSmallIntegerField(_('hour'))
40 class Meta:
41 ordering = ['hour']
43 def __str__(self):
44 return str(self.hour)
47class Subject(TimeStampedModel):
48 privacy_level = models.PositiveIntegerField(
49 _('Privacy level'),
50 default=0,
51 choices=[
52 (0, _('0 (regular)')),
53 (1, _('1 (increased)')),
54 (2, _('2 (high)')),
55 ],
56 )
58 to_be_deleted = models.DateField(_('To be deleted'), blank=True, null=True, help_text=_(
59 'All data about this subject should be deleted or fully anonymized. '
60 'This includes all data in Castellum and all data collected in studies. '
61 'This option should be used when a subject requests GDPR deletion.'
62 ))
63 to_be_deleted_notified = models.BooleanField(default=False)
65 export_requested = models.DateField(_('Export requested'), blank=True, null=True, help_text=_(
66 'The subject wants to receive an export of all data we have stored about them. '
67 'This includes all data in Castellum and all data collected in studies. '
68 'This option should be used when a subject requests GDPR export.'
69 ))
71 source = models.CharField(_('Data source'), max_length=128, blank=True)
73 note_hard_of_hearing = models.BooleanField(_('Hard of hearing'), default=False)
74 note_difficult_to_understand = models.BooleanField(_('Difficult to understand'), default=False)
75 note_abusive_language = models.BooleanField(_('Abusive language'), default=False)
77 availability_monday = models.ManyToManyField(
78 TimeSlot, verbose_name=_('Monday'), blank=True, related_name='+'
79 )
80 availability_tuesday = models.ManyToManyField(
81 TimeSlot, verbose_name=_('Tuesday'), blank=True, related_name='+'
82 )
83 availability_wednesday = models.ManyToManyField(
84 TimeSlot, verbose_name=_('Wednesday'), blank=True, related_name='+'
85 )
86 availability_thursday = models.ManyToManyField(
87 TimeSlot, verbose_name=_('Thursday'), blank=True, related_name='+'
88 )
89 availability_friday = models.ManyToManyField(
90 TimeSlot, verbose_name=_('Friday'), blank=True, related_name='+'
91 )
93 not_available_until = DateTimeField(_("not available until"), blank=True, null=True)
95 class Meta:
96 verbose_name = _('Subject')
98 def delete(self):
99 self.contact.delete()
100 return super().delete()
102 def get_field_names(self):
103 return [
104 'privacy_level',
105 'availability_monday',
106 'availability_tuesday',
107 'availability_wednesday',
108 'availability_thursday',
109 'availability_friday',
110 'not_available_until',
111 'note_hard_of_hearing',
112 'note_difficult_to_understand',
113 'note_abusive_language',
114 'source',
115 'to_be_deleted',
116 'export_requested',
117 ]
119 @cached_property
120 def contact(self):
121 from castellum.contacts.models import Contact
122 contact = Contact.objects.get(subject_id=self.pk)
123 contact.__dict__['subject'] = self
124 return contact
126 @cached_property
127 def has_consent(self):
128 return Consent.objects.filter(
129 subject=self,
130 status=Consent.CONFIRMED,
131 document__is_valid=True,
132 ).exists()
134 @cached_property
135 def has_consent_or_waiting(self):
136 from castellum.recruitment import filter_queries
137 return Subject.objects.filter(
138 filter_queries.has_consent(include_waiting=True), pk=self.pk,
139 ).exists()
141 @property
142 def has_consent_from_before_full_age(self):
143 if not self.has_consent or not self.contact.date_of_birth:
144 return False
145 today = datetime.date.today()
146 full_age = self.contact.date_of_birth + relativedelta(years=settings.CASTELLUM_FULL_AGE)
147 return full_age < today and full_age > self.consent.updated_at.date()
149 @cached_property
150 def has_study_consent(self):
151 from castellum.recruitment.models import ParticipationRequest
152 return self.participationrequest_set.filter(status=ParticipationRequest.INVITED).exists()
154 @property
155 def has_legal_basis(self):
156 return self.has_consent_or_waiting or self.has_study_consent or self.contact.is_guardian
158 @property
159 def is_available(self):
160 now = timezone.localtime()
161 if self.not_available_until and self.not_available_until > now:
162 return False
163 days = [
164 self.availability_monday,
165 self.availability_tuesday,
166 self.availability_wednesday,
167 self.availability_thursday,
168 self.availability_friday,
169 ]
170 if now.weekday() >= len(days):
171 return False
172 hours = days[now.weekday()]
173 return hours.filter(hour=now.hour).exists()
175 def get_next_available_datetime(self, start):
176 days = [
177 self.availability_monday,
178 self.availability_tuesday,
179 self.availability_wednesday,
180 self.availability_thursday,
181 self.availability_friday,
182 None,
183 None,
184 ]
186 # +1 accounts for the hours before start.hour on the
187 # first weekday we are checking
188 sliced_days = days[start.weekday():] + days[:start.weekday() + 1]
189 start = timezone.localtime(start)
190 for i, hours in enumerate(sliced_days):
191 if not hours:
192 continue
193 if i == 0:
194 hours = hours.filter(hour__gte=start.hour)
195 earliest_hour = hours.first()
196 if earliest_hour:
197 return start.replace(
198 hour=earliest_hour.hour, minute=0, second=0, microsecond=0
199 ) + timezone.timedelta(days=i)
201 @cached_property
202 def next_available(self):
203 now = timezone.localtime()
204 if self.not_available_until and self.not_available_until > now: 204 ↛ 205line 204 didn't jump to line 205, because the condition on line 204 was never true
205 return self.get_next_available_datetime(self.not_available_until)
206 else:
207 return self.get_next_available_datetime(now)
210class SubjectNote(models.Model):
211 subject = models.ForeignKey(Subject, on_delete=models.CASCADE)
212 content = models.CharField(_('Content'), max_length=128, blank=True)
215class ConsentDocument(models.Model):
216 created_at = models.DateTimeField(_('Created at'), auto_now_add=True)
217 is_valid = models.BooleanField(_('Is valid'), default=True)
218 is_deprecated = models.BooleanField(_('Is deprecated'), default=False)
219 file = RestrictedFileField(
220 _('File'),
221 blank=True,
222 upload_to='consent/',
223 content_types=['application/pdf'],
224 max_upload_size=settings.CASTELLUM_FILE_UPLOAD_MAX_SIZE,
225 )
227 class Meta:
228 get_latest_by = 'created_at'
230 def __str__(self):
231 return _('Version %i') % self.pk
234class Consent(models.Model):
235 WAITING = 'waiting'
236 CONFIRMED = 'confirmed'
238 updated_at = models.DateTimeField(_('Updated at'), auto_now=True)
239 subject = models.OneToOneField(Subject, on_delete=models.CASCADE)
240 document = models.ForeignKey(ConsentDocument, on_delete=models.CASCADE)
241 status = models.CharField(_('Status'), max_length=64, choices=[
242 (WAITING, _('Waiting for confirmation')),
243 (CONFIRMED, _('Confirmed')),
244 ])
246 class Meta:
247 get_latest_by = 'document__created_at'
248 verbose_name = _('Consent')
251class ExportAnswer(models.Model):
252 subject = models.ForeignKey(Subject, on_delete=models.CASCADE)
253 created_at = models.DateField(_('Created at'), auto_now_add=True)
254 created_by = models.CharField(_('Created by'), max_length=150)
256 class Meta:
257 verbose_name = _('Export answer')