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.core.exceptions import ObjectDoesNotExist
25from django.db import models
26from django.forms import ValidationError
27from django.utils.functional import cached_property
28from django.utils.translation import gettext_lazy as _
30from castellum.pseudonyms.helpers import get_pseudonym
31from castellum.studies.models import Study
32from castellum.subjects.models import Subject
33from castellum.utils.fields import DateField
34from castellum.utils.fields import DateTimeField
37class ParticipationRequestManager(models.Manager):
38 def get_queryset(self):
39 others = ParticipationRequestRevision.objects\
40 .filter(current_id=models.OuterRef('id'))\
41 .exclude(status=models.OuterRef('status'))
42 other_status_at = others.order_by('-created_at').values('created_at')[:1]
44 return super().get_queryset().annotate(
45 updated_at=models.Max('revisions__created_at'),
46 created_at=models.Min('revisions__created_at'),
47 status_not_reached=models.Count(
48 'id',
49 filter=models.Q(status__in=[
50 ParticipationRequest.NOT_REACHED,
51 ParticipationRequest.AWAITING_RESPONSE,
52 ]),
53 distinct=True,
54 ),
55 followup_urgent=models.Count(
56 'id', filter=models.Q(followup_date__lte=datetime.date.today())
57 ),
58 other_status_at=models.Subquery(other_status_at),
59 tries=models.Count(
60 'revisions',
61 filter=(
62 models.Q(revisions__created_at__gt=models.F('other_status_at')) |
63 models.Q(other_status_at__isnull=True)
64 ),
65 distinct=True,
66 ),
67 last_contacted_elsewhere=models.Max(
68 'subject__participationrequest__revisions__created_at',
69 filter=(
70 ~models.Q(subject__participationrequest__study=models.F('study'))
71 & ~models.Q(
72 subject__participationrequest__revisions__status=ParticipationRequest.NOT_CONTACTED # noqa
73 )
74 ),
75 ),
76 )
79class ParticipationRequest(models.Model):
80 NOT_CONTACTED = 0
81 NOT_REACHED = 1
82 UNSUITABLE = 2
83 INVITED = 3
84 FOLLOWUP_APPOINTED = 4
85 AWAITING_RESPONSE = 5
86 STATUS_OPTIONS = (
87 (NOT_CONTACTED, _('not contacted')),
88 (NOT_REACHED, _('not reached')),
89 (UNSUITABLE, _('unsuitable')),
90 (INVITED, _('invited')),
91 (FOLLOWUP_APPOINTED, _('follow-up scheduled')),
92 (AWAITING_RESPONSE, _('awaiting response')),
93 )
95 subject = models.ForeignKey(Subject, on_delete=models.CASCADE, editable=False)
96 study = models.ForeignKey(Study, verbose_name=_('Study'), on_delete=models.CASCADE)
98 status = models.IntegerField(
99 _('Status of participation request'), choices=STATUS_OPTIONS, default=NOT_CONTACTED
100 )
101 exclusion_criteria_checked = models.BooleanField(_('Subject may be suitable'), default=False)
103 followup_date = DateField(_('Follow-up date'), blank=True, null=True)
104 followup_time = models.TimeField(_('Follow-up time'), blank=True, null=True)
106 objects = ParticipationRequestManager()
108 class Meta:
109 verbose_name = _('Participation request')
110 verbose_name_plural = _('Participation requests')
111 unique_together = ('study', 'subject')
112 permissions = (
113 ('view_participation_pseudonyms', _('Can view participation pseudonyms')),
114 ('search_execution', _('Can search in execution')),
115 )
117 @property
118 def status_open(self):
119 return self.status in [
120 ParticipationRequest.NOT_CONTACTED,
121 ParticipationRequest.NOT_REACHED,
122 ParticipationRequest.FOLLOWUP_APPOINTED,
123 ParticipationRequest.AWAITING_RESPONSE,
124 ]
126 @cached_property
127 def pseudonym(self):
128 """Note that the pseudonym is generated on demand."""
129 return get_pseudonym(self.subject, self.study.domain)
131 @cached_property
132 def appointment_today(self):
133 return self.appointment_set.filter(start__date=datetime.date.today()).exists()
135 @cached_property
136 def match(self):
137 from castellum.recruitment import filter_queries
138 if Subject.objects.filter( 138 ↛ 143line 138 didn't jump to line 143, because the condition on line 138 was never false
139 filter_queries.study(self.study, include_unknown=False),
140 pk=self.subject.pk,
141 ).exists():
142 return 'complete'
143 elif Subject.objects.filter(
144 filter_queries.study(self.study, include_unknown=True),
145 pk=self.subject.pk,
146 ).exists():
147 return 'incomplete'
148 else:
149 return False
151 def get_appointments(self):
152 for session in self.study.studysession_set.order_by('pk'):
153 try:
154 appointment = self.appointment_set.get(session=session)
155 except ObjectDoesNotExist:
156 appointment = None
157 yield session, appointment
159 def clean(self):
160 if self.status == self.FOLLOWUP_APPOINTED and not self.followup_date: 160 ↛ 161line 160 didn't jump to line 161, because the condition on line 160 was never true
161 raise ValidationError(_('No follow-up set'), code='invalid')
162 if self.status != self.FOLLOWUP_APPOINTED and (self.followup_date or self.followup_time): 162 ↛ 163line 162 didn't jump to line 163, because the condition on line 162 was never true
163 raise ValidationError(_('Follow-up cannot be set with this status'), code='invalid')
164 if ( 164 ↛ 169line 164 didn't jump to line 169
165 self.status == self.INVITED
166 and self.study.has_exclusion_criteria
167 and not self.exclusion_criteria_checked
168 ):
169 msg = _('Inclusion/exclusion criteria have not been checked')
170 raise ValidationError(msg, code='invalid')
172 def save(self, *args, **kwargs):
173 super().save(*args, **kwargs)
174 ParticipationRequestRevision.objects.create(
175 current=self,
176 status=self.status,
177 exclusion_criteria_checked=self.exclusion_criteria_checked,
178 followup_date=self.followup_date,
179 followup_time=self.followup_time,
180 )
183class ParticipationRequestRevision(models.Model):
184 """Revision history for ParticipationRequest.
186 Each time a ParticipationRequest is saved, an instance of ParticipationRequestRevision
187 if created. This way, we have a history of the ParticipationRequest.
189 The history is saved in separate model because of these reasons:
191 - ``ParticipationRequest.pseudonym`` is unique.
192 - Saving the history in the same ParticipationRequest model would degrade
193 performance because the table would be filled up with old revision.
194 """
196 current = models.ForeignKey(
197 ParticipationRequest, related_name='revisions', on_delete=models.CASCADE)
198 status = models.IntegerField(
199 choices=ParticipationRequest.STATUS_OPTIONS, default=ParticipationRequest.NOT_CONTACTED
200 )
201 exclusion_criteria_checked = models.BooleanField()
202 followup_date = DateField(blank=True, null=True)
203 followup_time = models.TimeField(blank=True, null=True)
204 created_at = DateTimeField(auto_now_add=True)
206 def __str__(self):
207 return '{}: {}'.format(self.created_at, self.get_status_display())
210class MailBatch(models.Model):
211 study = models.ForeignKey(Study, verbose_name=_('Study'), on_delete=models.CASCADE)
212 datetime = models.DateTimeField(_('Sent date'), auto_now_add=True)
213 contacted_size = models.PositiveIntegerField()