diff --git a/castellum/studies/templates/studies/study_detail.html b/castellum/studies/templates/studies/study_detail.html index 8b0d00a8f9805a3f60e23ed214337fb165f27554..ec1eb87055be6a3a59ceb9d3769bbec191f2714a 100644 --- a/castellum/studies/templates/studies/study_detail.html +++ b/castellum/studies/templates/studies/study_detail.html @@ -58,6 +58,15 @@ {% endfor %} +
{% translate 'Studies which will recruit similar subjects at the same time' %}
+
+ {% for study in conflicting_studies %} + {{ study }} ({{ study.count }}){% if not forloop.last %},{% endif %} + {% empty %} + — + {% endfor %} +
+ {% if not object.is_onetime_invitation %}
{% translate 'Recruitment text provided' %}
{% if object.recruitment_text %}{% translate 'Yes' %}{% else %}{% translate 'No' %}{% endif %}
diff --git a/castellum/studies/views/recruitment.py b/castellum/studies/views/recruitment.py index 3e547d31ff0fc5d98a3fbb63e0b7fabf62fc536b..11424ef922be30a103ca187df15cf8abb16b31fb 100644 --- a/castellum/studies/views/recruitment.py +++ b/castellum/studies/views/recruitment.py @@ -45,6 +45,12 @@ from ..models import Study def get_related_studies(study): + """Get studies in which potential subjects have participated. + + This is useful to find candidates for excluded_studies to avoid + training effects. + """ + # if no filters exist, all active studies would be related if not SubjectFilter.objects.filter(group__study=study).exists(): return Study.objects.none() @@ -62,6 +68,34 @@ def get_related_studies(study): .order_by('-count')[:5] +def get_conflicting_studies(study): + """Get studies which will recruit similar subjects at the same time. + + This is useful to avoid recruitment bottlenecks. + """ + + if not study.sessions_start or not study.sessions_end: + return [] + + studies = Study.objects.filter( + sessions_start__lte=study.sessions_end, sessions_end__gte=study.sessions_start + ).exclude(pk=study.pk) + + results = [] + for other in studies: + subjects = Subject.objects.filter(filter_queries.study(other)) + intersection = subjects.filter(filter_queries.study(study)) + count = subjects.count() + if count == 0: + other.count = 0 + else: + other.count = int(other.min_subject_count / count * intersection.count()) + if other.count != 0: + results.append(other) + + return sorted(results, key=lambda s: -s.count)[:5] + + class StudyRecruitmentSettingsUpdateView(StudyMixin, PermissionRequiredMixin, UpdateView): model = Study pk_url_kwarg = 'study_pk' diff --git a/castellum/studies/views/studies.py b/castellum/studies/views/studies.py index 271ef4b7187fc842d4d14d993421e43ba33b9cd2..78098766ff84d17b659154a9919564ae14cdc338 100644 --- a/castellum/studies/views/studies.py +++ b/castellum/studies/views/studies.py @@ -63,6 +63,7 @@ from ..forms import StudyForm from ..mixins import StudyMixin from ..models import Study from ..models import StudyMembership +from .recruitment import get_conflicting_studies from .recruitment import get_related_studies monitoring_logger = logging.getLogger('monitoring.studies') @@ -202,6 +203,7 @@ class StudyDetailView(PermissionRequiredMixin, DetailView): ).count() ) context['related_studies'] = list(get_related_studies(self.object)) + context['conflicting_studies'] = get_conflicting_studies(self.object) return context diff --git a/tests/studies/views/test_conflicting_studies.py b/tests/studies/views/test_conflicting_studies.py new file mode 100644 index 0000000000000000000000000000000000000000..45092ad0a5f55fee42b16d5815136c1518376031 --- /dev/null +++ b/tests/studies/views/test_conflicting_studies.py @@ -0,0 +1,75 @@ +import datetime + +from model_bakery import baker + +from castellum.recruitment.models import SubjectFilter +from castellum.studies.models import Study +from castellum.studies.views.recruitment import get_conflicting_studies + + +def test_conflicting_studies(subject): + study = baker.make( + Study, + sessions_start=datetime.date(2000, 1, 1), + sessions_end=datetime.date(2000, 3, 1), + ) + + other = baker.make( + Study, + min_subject_count=1, + sessions_start=datetime.date(2000, 1, 1), + sessions_end=datetime.date(2000, 3, 1), + ) + + assert other in get_conflicting_studies(study) + + +def test_conflicting_studies_filter(subject, attribute_description): + subject.attributes[attribute_description.json_key] = 1 + subject.save() + + study = baker.make( + Study, + sessions_start=datetime.date(2000, 1, 1), + sessions_end=datetime.date(2000, 3, 1), + ) + baker.make( + SubjectFilter, + group__study=study, + description=attribute_description, + operator='exact', + value=1, + ) + + other = baker.make( + Study, + min_subject_count=1, + sessions_start=datetime.date(2000, 1, 1), + sessions_end=datetime.date(2000, 3, 1), + ) + baker.make( + SubjectFilter, + group__study=other, + description=attribute_description, + operator='exact', + value=2, + ) + + assert other not in get_conflicting_studies(study) + + +def test_conflicting_studies_time(subject): + study = baker.make( + Study, + sessions_start=datetime.date(2000, 1, 1), + sessions_end=datetime.date(2000, 3, 1), + ) + + other = baker.make( + Study, + min_subject_count=1, + sessions_start=datetime.date(2000, 4, 1), + sessions_end=datetime.date(2000, 5, 1), + ) + + assert other not in get_conflicting_studies(study) diff --git a/tests/studies/views/test_related_studies.py b/tests/studies/views/test_related_studies.py new file mode 100644 index 0000000000000000000000000000000000000000..61e5957153f0589cc4412c8f3516a4c561a4be12 --- /dev/null +++ b/tests/studies/views/test_related_studies.py @@ -0,0 +1,48 @@ +from model_bakery import baker + +from castellum.recruitment.models import Participation +from castellum.recruitment.models import SubjectFilter +from castellum.studies.models import Study +from castellum.studies.views.recruitment import get_related_studies + + +def test_related_studies(attribute_description): + study = baker.make(Study) + baker.make( + SubjectFilter, + group__study=study, + description=attribute_description, + operator='exact', + value=1, + ) + + participation = baker.make( + Participation, + status=Participation.INVITED, + subject__attributes={ + attribute_description.json_key: 1, + }, + ) + + assert participation.study in get_related_studies(study) + + +def test_related_studies_filter(attribute_description): + study = baker.make(Study) + baker.make( + SubjectFilter, + group__study=study, + description=attribute_description, + operator='exact', + value=1, + ) + + participation = baker.make( + Participation, + status=Participation.INVITED, + subject__attributes={ + attribute_description.json_key: 2, + }, + ) + + assert participation.study not in get_related_studies(study)