From 5a45d914ad1a5675d2e5be6c72f67bfcf34c58be Mon Sep 17 00:00:00 2001 From: Tobias Bengfort Date: Tue, 26 Oct 2021 17:11:35 +0200 Subject: [PATCH 1/3] test get_related_studies --- castellum/studies/views/recruitment.py | 6 +++ tests/studies/views/test_related_studies.py | 48 +++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 tests/studies/views/test_related_studies.py diff --git a/castellum/studies/views/recruitment.py b/castellum/studies/views/recruitment.py index 3e547d31f..1c1c3db0c 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() diff --git a/tests/studies/views/test_related_studies.py b/tests/studies/views/test_related_studies.py new file mode 100644 index 000000000..61e595715 --- /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) -- GitLab From 2396d6085802d0ac0cd3b706e754e55bd2ae422a Mon Sep 17 00:00:00 2001 From: Tobias Bengfort Date: Tue, 26 Oct 2021 17:11:45 +0200 Subject: [PATCH 2/3] add conflicting studies --- .../templates/studies/study_detail.html | 9 +++ castellum/studies/views/recruitment.py | 28 +++++++ castellum/studies/views/studies.py | 2 + .../studies/views/test_conflicting_studies.py | 75 +++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 tests/studies/views/test_conflicting_studies.py diff --git a/castellum/studies/templates/studies/study_detail.html b/castellum/studies/templates/studies/study_detail.html index 8b0d00a8f..ec1eb8705 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 1c1c3db0c..11424ef92 100644 --- a/castellum/studies/views/recruitment.py +++ b/castellum/studies/views/recruitment.py @@ -68,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 2111e20e4..a35af076a 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') @@ -203,6 +204,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 000000000..45092ad0a --- /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) -- GitLab From d7a067e1831a2f8422779d915149c1e6a3f2e421 Mon Sep 17 00:00:00 2001 From: Tobias Bengfort Date: Mon, 1 Nov 2021 16:29:57 +0100 Subject: [PATCH 3/3] display conflicting studies as chart --- .../templates/studies/study_detail.html | 41 +++++++++++++------ castellum/studies/views/recruitment.py | 2 +- castellum/studies/views/studies.py | 16 +++++++- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/castellum/studies/templates/studies/study_detail.html b/castellum/studies/templates/studies/study_detail.html index ec1eb8705..12e8aa6d2 100644 --- a/castellum/studies/templates/studies/study_detail.html +++ b/castellum/studies/templates/studies/study_detail.html @@ -43,11 +43,35 @@

{% translate "Recruitment settings" %}

-
{% translate 'Participating subjects' %}
-
{{ object.invited_count }}/{{ object.min_subject_count }}
+
{% translate 'Participating subjects' %}
+
+ + + + + {{ object.invited_count }}/{{ object.min_subject_count }} +
-
{% translate 'Potential subjects' %}
-
{{ potential_count }}
+
{% translate 'Potential subjects' %}
+
+ {% translate 'There should be enough potential subjects for this study and other studies which will recruit similar subjects at the same time.' %} + + {% for label, count, width in conflicting_studies reversed %} + + {% endfor %} + + + +
+
{{ object }}
+
{{ object.min_subject_count }}/{{ potential_count }}
+ + {% for label, count, width in conflicting_studies %} +
{{ label }}
+
{{ count }}/{{ potential_count }}
+ {% endfor %} +
+
{% translate 'Studies in which potential subjects have participated' %}
@@ -58,15 +82,6 @@ {% 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 11424ef92..cd450c7c2 100644 --- a/castellum/studies/views/recruitment.py +++ b/castellum/studies/views/recruitment.py @@ -93,7 +93,7 @@ def get_conflicting_studies(study): if other.count != 0: results.append(other) - return sorted(results, key=lambda s: -s.count)[:5] + return sorted(results, key=lambda s: -s.count) class StudyRecruitmentSettingsUpdateView(StudyMixin, PermissionRequiredMixin, UpdateView): diff --git a/castellum/studies/views/studies.py b/castellum/studies/views/studies.py index a35af076a..c75c275ca 100644 --- a/castellum/studies/views/studies.py +++ b/castellum/studies/views/studies.py @@ -204,7 +204,21 @@ class StudyDetailView(PermissionRequiredMixin, DetailView): ).count() ) context['related_studies'] = list(get_related_studies(self.object)) - context['conflicting_studies'] = get_conflicting_studies(self.object) + + width = self.object.min_subject_count + conflicting_studies = get_conflicting_studies(self.object) + context['conflicting_studies'] = [] + for study in conflicting_studies[:2]: + width += study.count + context['conflicting_studies'].append((study, study.count, width)) + if len(conflicting_studies) > 2: + others = sum(study.count for study in conflicting_studies[2:]) + width += others + context['conflicting_studies'].append((_('Others'), others, width)) + context['expected_subjects_threshold'] = ( + context['potential_count'] / settings.CASTELLUM_EXPECTED_SUBJECT_FACTOR + ) + return context -- GitLab