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
23import json
24import os
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 _
33from parler.models import TranslatableModel
34from parler.models import TranslatedFields
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
43APP_DIR = os.path.dirname(__file__)
44GEOJSON_SCHEMA = json.load(open(os.path.join(APP_DIR, 'schemas', 'geojson.json')))
47class StudyType(TranslatableModel):
48 # see also schemas/study.json
49 EXPORT_KEYS = 'Online', 'Behavioral lab', 'MRI', 'Simulation', 'EEG'
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 )
63 def __str__(self):
64 return self.label
67class Study(models.Model):
68 EDIT = 0
69 EXECUTION = 1
70 FINISHED = 2
72 STATUS = [
73 (EDIT, _('Edit')),
74 (EXECUTION, _('Execution')),
75 (FINISHED, _('Finished')),
76 ]
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)
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 )
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 )
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)
187 class Meta:
188 ordering = ['name']
189 permissions = (
190 ('access_study', _('Can access studies')),
191 )
193 def __str__(self):
194 return self.name
196 def restore_previous_status(self):
197 self.status = self.previous_status
198 self.save()
200 def set_status(self, status):
201 self.previous_status = self.status
202 self.status = status
203 self.save()
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 )
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 )
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)
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)
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
240 @property
241 def study_type(self):
242 return StudyType.objects.filter(studysession__study=self).distinct()
244 @property
245 def followup_urgent(self):
246 today = datetime.date.today()
247 return self.participationrequest_set.filter(followup_date__lte=today).exists()
249 @cached_property
250 def has_filters(self):
251 from castellum.recruitment.models import SubjectFilter
252 return SubjectFilter.objects.filter(group__study=self).exists()
254 @cached_property
255 def has_exclusion_criteria(self):
256 return self.exclusion_criteria or self.study_type.exclude(exclusion_criteria='').exists()
258 @cached_property
259 def is_sensitive(self):
260 return self.data_sensitivity or self.study_type.filter(data_sensitivity=True).exists()
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
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 ])
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='+')
289 class Meta:
290 unique_together = ('study', 'user')
292 def __str__(self):
293 return '{}:{}'.format(self.study, self.user)
296class Resource(models.Model):
297 name = models.CharField(_('Name'), max_length=128)
299 def __str__(self):
300 return self.name
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 )
312 def __str__(self):
313 return '{} - {}'.format(self.study, self.name)