Hide keyboard shortcuts

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/>. 

21 

22import datetime 

23 

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 _ 

29 

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 

35 

36 

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] 

43 

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 ) 

77 

78 

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 ) 

94 

95 subject = models.ForeignKey(Subject, on_delete=models.CASCADE, editable=False) 

96 study = models.ForeignKey(Study, verbose_name=_('Study'), on_delete=models.CASCADE) 

97 

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) 

102 

103 followup_date = DateField(_('Follow-up date'), blank=True, null=True) 

104 followup_time = models.TimeField(_('Follow-up time'), blank=True, null=True) 

105 

106 objects = ParticipationRequestManager() 

107 

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 ) 

116 

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 ] 

125 

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) 

130 

131 @cached_property 

132 def appointment_today(self): 

133 return self.appointment_set.filter(start__date=datetime.date.today()).exists() 

134 

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 

150 

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 

158 

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') 

171 

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 ) 

181 

182 

183class ParticipationRequestRevision(models.Model): 

184 """Revision history for ParticipationRequest. 

185 

186 Each time a ParticipationRequest is saved, an instance of ParticipationRequestRevision 

187 if created. This way, we have a history of the ParticipationRequest. 

188 

189 The history is saved in separate model because of these reasons: 

190 

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 """ 

195 

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) 

205 

206 def __str__(self): 

207 return '{}: {}'.format(self.created_at, self.get_status_display()) 

208 

209 

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()