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 

23import json 

24import os 

25 

26from django.conf import settings 

27from django.contrib.auth.models import Group 

28from django.db import models 

29from django.forms import ValidationError 

30from django.utils.functional import cached_property 

31from django.utils.translation import gettext_lazy as _ 

32 

33from parler.models import TranslatableModel 

34from parler.models import TranslatedFields 

35 

36from castellum.castellum_auth.models import User 

37from castellum.pseudonyms.helpers import create_domain 

38from castellum.pseudonyms.helpers import delete_domain 

39from castellum.utils.fields import DateField 

40from castellum.utils.fields import RestrictedFileField 

41from castellum.utils.forms import JsonFileValidator 

42 

43APP_DIR = os.path.dirname(__file__) 

44GEOJSON_SCHEMA = json.load(open(os.path.join(APP_DIR, 'schemas', 'geojson.json'))) 

45 

46 

47class StudyType(TranslatableModel): 

48 # see also schemas/study.json 

49 EXPORT_KEYS = 'Online', 'Behavioral lab', 'MRI', 'Simulation', 'EEG' 

50 

51 translations = TranslatedFields( 

52 label=models.CharField(_('Label'), max_length=64), 

53 ) 

54 exclusion_criteria = models.TextField( 

55 _('Additional subject characteristics that should be verified during recruitment'), 

56 blank=True, 

57 ) 

58 data_sensitivity = models.BooleanField(_('Contains sensitive data'), default=False) 

59 export_key = models.CharField( 

60 _('Export key'), max_length=32, choices=[(k, k) for k in EXPORT_KEYS], blank=True 

61 ) 

62 

63 def __str__(self): 

64 return self.label 

65 

66 

67class Study(models.Model): 

68 EDIT = 0 

69 EXECUTION = 1 

70 FINISHED = 2 

71 

72 STATUS = [ 

73 (EDIT, _('Edit')), 

74 (EXECUTION, _('Execution')), 

75 (FINISHED, _('Finished')), 

76 ] 

77 

78 name = models.CharField(_('Name'), max_length=254) 

79 contact_person = models.CharField(_('Responsible contact person'), max_length=254) 

80 principal_investigator = models.CharField(_('Principal Investigator'), max_length=254) 

81 affiliated_scientists = models.CharField(_('Affiliated Scientists'), max_length=254, blank=True) 

82 affiliated_research_assistants = models.CharField( 

83 _('Affiliated Research Assistants'), max_length=254, blank=True 

84 ) 

85 description = models.TextField(_('Description'), blank=True) 

86 keywords = models.CharField(_('Keywords'), max_length=254, blank=True) 

87 previous_status = models.SmallIntegerField( 

88 _('Previous status'), choices=STATUS, default=EDIT 

89 ) 

90 status = models.SmallIntegerField(_('Status'), choices=STATUS, default=EDIT) 

91 data_sensitivity = models.BooleanField(_('Contains sensitive data'), default=False) 

92 min_subject_count = models.PositiveIntegerField(_('Required number of subjects'), default=0) 

93 session_instructions = models.TextField( 

94 _('Session instructions'), 

95 help_text=_( 

96 'Please describe any requirements for carrying out the sessions listed below. ' 

97 'For example, specify time intervals between sessions that need to be ' 

98 'considered for booking appointments.' 

99 ), 

100 blank=True, 

101 ) 

102 sessions_start = DateField(_('Start of test sessions'), blank=True, null=True) 

103 sessions_end = DateField(_('End of test sessions'), blank=True, null=True) 

104 exclusion_criteria = models.TextField( 

105 _('Additional subject characteristics that should be verified during recruitment'), 

106 blank=True, 

107 ) 

108 recruitment_text = models.TextField( 

109 _('Recruitment text'), 

110 help_text=_( 

111 'This text will be used during recruitment dialogue. Thus, please describe the study ' 

112 'from the perspective of recruiters and potential participants: What is it about? ' 

113 'How long will it take? Are there any potential benefits or risks for participants? ' 

114 'If applicable, also present the amount of expense allowance and related requirements.' 

115 ), 

116 blank=True, 

117 ) 

118 to_be_deleted_notified = models.BooleanField(default=False) 

119 

120 members = models.ManyToManyField(User, through='StudyMembership') 

121 domain = models.CharField(max_length=64, unique=True, editable=False, default=None) 

122 advanced_filtering = models.BooleanField( 

123 _('Advanced filtering'), 

124 help_text=_( 

125 'By default you can create only one filtergroup in which all the filters are joined by ' 

126 'AND operator. Advanced filtering allows you to create multiple filtergroups which are ' 

127 'joined by OR operator.' 

128 ), 

129 default=False, 

130 ) 

131 custom_filter = models.CharField(_('Custom filter'), max_length=254, blank=True) 

132 geo_filter = RestrictedFileField( 

133 _('Geo filter file'), 

134 help_text=_( 

135 'A GeoJSON file that contains only a single (multi)polygon. ' 

136 'Only subjects who live inside this polygon will be considered for this study.' 

137 ), 

138 blank=True, 

139 upload_to='studies/geofilters/', 

140 content_types=['application/json', 'text/plain'], 

141 max_upload_size=settings.CASTELLUM_FILE_UPLOAD_MAX_SIZE, 

142 validators=[JsonFileValidator(GEOJSON_SCHEMA, '#/$defs/Feature')], 

143 ) 

144 is_exclusive = models.BooleanField( 

145 _('Exclusive subjects'), 

146 help_text=_( 

147 'When set, this ensures that potential subjects for this study will not be recruited ' 

148 'in other studies. Please note that this may hinder other researchers in finding ' 

149 'enough participants.' 

150 ), 

151 default=False, 

152 ) 

153 complete_matches_only = models.BooleanField( 

154 _('Complete filter matches only'), 

155 help_text=_( 

156 'By default, filters may include subjects with incomplete attribute sets. This feature ' 

157 'is supposed to improve the quality of the database by asking recruiters to fill ' 

158 'missing values on the go. Only allowing complete filter matches can speed up ' 

159 'individual recruitments but may deteriorate the quality of the database as a whole. ' 

160 'Furthermore, this reduces the number of potential subjects.' 

161 ), 

162 default=False, 

163 ) 

164 excluded_studies = models.ManyToManyField( 

165 'studies.Study', verbose_name=_('Excluded studies'), related_name='+', blank=True 

166 ) 

167 

168 consent = RestrictedFileField( 

169 _('Consent'), 

170 blank=True, 

171 upload_to='studies/consent/', 

172 content_types=['application/pdf'], 

173 max_upload_size=settings.CASTELLUM_FILE_UPLOAD_MAX_SIZE, 

174 ) 

175 

176 mail_subject = models.CharField(_('E-mail subject'), max_length=254, blank=True) 

177 mail_body = models.TextField( 

178 _('E-mail body'), 

179 help_text=_( 

180 'Any "{firstname}" and "{lastname}"-tags included in the e-mail-body will ' 

181 'automatically be replaced with the first and last name of the subject.' 

182 ), 

183 blank=True, 

184 ) 

185 mail_reply_address = models.EmailField(_('Reply e-mail-address'), max_length=128, blank=True) 

186 

187 class Meta: 

188 ordering = ['name'] 

189 permissions = ( 

190 ('access_study', _('Can access studies')), 

191 ) 

192 

193 def __str__(self): 

194 return self.name 

195 

196 def restore_previous_status(self): 

197 self.status = self.previous_status 

198 self.save() 

199 

200 def set_status(self, status): 

201 self.previous_status = self.status 

202 self.status = status 

203 self.save() 

204 

205 def clean(self): 

206 if self.subjectfiltergroup_set.count() > 1 and not self.advanced_filtering: 206 ↛ 207line 206 didn't jump to line 207, because the condition on line 206 was never true

207 raise ValidationError( 

208 _( 

209 'Studies with more than one filtergroup can\'t be transformed back to basic ' 

210 'filtering. Delete all but one filtergroup first!' 

211 ), 

212 code='invalid', 

213 ) 

214 

215 mailfields = [self.mail_subject, self.mail_body, self.mail_reply_address] 

216 if any(mailfields) and not all(mailfields): 216 ↛ 217line 216 didn't jump to line 217, because the condition on line 216 was never true

217 raise ValidationError( 

218 _( 

219 'Mail settings have to be filled out completely if you wish to use ' 

220 'this feature.' 

221 ), 

222 code='invalid', 

223 ) 

224 

225 def save(self, *args, **kwargs): 

226 if not self.domain: 

227 self.domain = create_domain(settings.CASTELLUM_STUDY_DOMAIN_BITS) 

228 return super().save(*args, **kwargs) 

229 

230 def delete(self, *args, **kwargs): 

231 if self.domain: 231 ↛ 233line 231 didn't jump to line 233, because the condition on line 231 was never false

232 delete_domain(self.domain) 

233 return super().delete(*args, **kwargs) 

234 

235 def get_filter_max_privacy_level(self): 

236 key = 'subjectfilter__description__privacy_level_read' 

237 result = self.subjectfiltergroup_set.aggregate(models.Max(key)) 

238 return result[key + '__max'] or 0 

239 

240 @property 

241 def study_type(self): 

242 return StudyType.objects.filter(studysession__study=self).distinct() 

243 

244 @property 

245 def followup_urgent(self): 

246 today = datetime.date.today() 

247 return self.participationrequest_set.filter(followup_date__lte=today).exists() 

248 

249 @cached_property 

250 def has_filters(self): 

251 from castellum.recruitment.models import SubjectFilter 

252 return SubjectFilter.objects.filter(group__study=self).exists() 

253 

254 @cached_property 

255 def has_exclusion_criteria(self): 

256 return self.exclusion_criteria or self.study_type.exclude(exclusion_criteria='').exists() 

257 

258 @cached_property 

259 def is_sensitive(self): 

260 return self.data_sensitivity or self.study_type.filter(data_sensitivity=True).exists() 

261 

262 # NOTE: this might not be the same as the group of the same name! 

263 @cached_property 

264 def recruiters(self): 

265 recruiters = [] 

266 for user in self.members.all(): 

267 if user.has_perm('recruitment.add_participationrequest', obj=self): 267 ↛ 266line 267 didn't jump to line 266, because the condition on line 267 was never false

268 recruiters.append(user) 

269 return recruiters 

270 

271 def has_missing_values(self): 

272 return not all([ 

273 self.principal_investigator, 

274 self.study_type.exists(), 

275 self.sessions_start, 

276 self.sessions_end, 

277 self.consent, 

278 self.recruiters, 

279 self.recruitment_text, 

280 self.has_filters, 

281 ]) 

282 

283 

284class StudyMembership(models.Model): 

285 study = models.ForeignKey(Study, on_delete=models.CASCADE) 

286 user = models.ForeignKey(User, on_delete=models.CASCADE) 

287 groups = models.ManyToManyField(Group, blank=True, related_name='+') 

288 

289 class Meta: 

290 unique_together = ('study', 'user') 

291 

292 def __str__(self): 

293 return '{}:{}'.format(self.study, self.user) 

294 

295 

296class Resource(models.Model): 

297 name = models.CharField(_('Name'), max_length=128) 

298 

299 def __str__(self): 

300 return self.name 

301 

302 

303class StudySession(models.Model): 

304 study = models.ForeignKey(Study, on_delete=models.CASCADE) 

305 name = models.CharField(_('Name'), max_length=128) 

306 duration = models.PositiveIntegerField(_('Duration of a session in minutes')) 

307 type = models.ManyToManyField(StudyType, verbose_name=_('Type'), blank=True) 

308 resource = models.ForeignKey( 

309 Resource, on_delete=models.SET_NULL, verbose_name=_('Resource'), blank=True, null=True 

310 ) 

311 

312 def __str__(self): 

313 return '{} - {}'.format(self.study, self.name)