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

29 

30from dateutil.relativedelta import relativedelta 

31 

32from castellum.utils.fields import DateTimeField 

33from castellum.utils.fields import RestrictedFileField 

34from castellum.utils.models import TimeStampedModel 

35 

36 

37class TimeSlot(models.Model): 

38 hour = models.PositiveSmallIntegerField(_('hour')) 

39 

40 class Meta: 

41 ordering = ['hour'] 

42 

43 def __str__(self): 

44 return str(self.hour) 

45 

46 

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 ) 

57 

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) 

64 

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

70 

71 source = models.CharField(_('Data source'), max_length=128, blank=True) 

72 

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) 

76 

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 ) 

92 

93 not_available_until = DateTimeField(_("not available until"), blank=True, null=True) 

94 

95 class Meta: 

96 verbose_name = _('Subject') 

97 

98 def delete(self): 

99 self.contact.delete() 

100 return super().delete() 

101 

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 ] 

118 

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 

125 

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

133 

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

140 

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

148 

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

153 

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 

157 

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

174 

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 ] 

185 

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) 

200 

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) 

208 

209 

210class SubjectNote(models.Model): 

211 subject = models.ForeignKey(Subject, on_delete=models.CASCADE) 

212 content = models.CharField(_('Content'), max_length=128, blank=True) 

213 

214 

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 ) 

226 

227 class Meta: 

228 get_latest_by = 'created_at' 

229 

230 def __str__(self): 

231 return _('Version %i') % self.pk 

232 

233 

234class Consent(models.Model): 

235 WAITING = 'waiting' 

236 CONFIRMED = 'confirmed' 

237 

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

245 

246 class Meta: 

247 get_latest_by = 'document__created_at' 

248 verbose_name = _('Consent') 

249 

250 

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) 

255 

256 class Meta: 

257 verbose_name = _('Export answer')