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)