diff --git a/atlas/migrations/0103_casecollection_auto_apply_question_template_and_more.py b/atlas/migrations/0103_casecollection_auto_apply_question_template_and_more.py
new file mode 100644
index 00000000..8e776cf5
--- /dev/null
+++ b/atlas/migrations/0103_casecollection_auto_apply_question_template_and_more.py
@@ -0,0 +1,38 @@
+# Generated by Django 6.0.1 on 2026-05-12 20:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('atlas', '0102_alter_seriesimage_image_blake3_hash_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='casecollection',
+ name='auto_apply_question_template',
+ field=models.BooleanField(default=False, help_text='If enabled, the case_question_template is automatically applied to new cases added to this collection that do not have an existing question_schema.'),
+ ),
+ migrations.AddField(
+ model_name='casecollection',
+ name='case_question_max_score',
+ field=models.PositiveIntegerField(blank=True, help_text='Maximum score per case used for normalisation. Defaults to 10 when not set.', null=True),
+ ),
+ migrations.AddField(
+ model_name='casecollection',
+ name='case_question_template',
+ field=models.JSONField(blank=True, help_text='JSON schema applied as a default question template for all cases in this collection. Individual cases can override via CaseDetail.question_schema.', null=True),
+ ),
+ migrations.AddField(
+ model_name='casecollection',
+ name='marking_guidance',
+ field=models.TextField(blank=True, help_text='Plain-language marking guidance displayed to markers.'),
+ ),
+ migrations.AddField(
+ model_name='casecollection',
+ name='marking_scheme_name',
+ field=models.CharField(blank=True, help_text='Human-readable mark scheme name shown to markers and in exports.', max_length=255),
+ ),
+ ]
diff --git a/atlas/models.py b/atlas/models.py
index a0974144..0f4afc3e 100644
--- a/atlas/models.py
+++ b/atlas/models.py
@@ -1458,6 +1458,47 @@ class CaseCollection(ExamOrCollectionGenericBase):
default=VIEWER_MODE_CHOICES.BUILT_IN,
)
+ # -----------------------------------------------------------------------
+ # Marking configuration
+ # These fields can be defined here so that the same CaseCollection can be
+ # reused in multiple research studies or standalone with consistent marking.
+ # -----------------------------------------------------------------------
+ marking_scheme_name = models.CharField(
+ max_length=255,
+ blank=True,
+ help_text="Human-readable mark scheme name shown to markers and in exports.",
+ )
+ marking_guidance = models.TextField(
+ blank=True,
+ help_text="Plain-language marking guidance displayed to markers.",
+ )
+ case_question_max_score = models.PositiveIntegerField(
+ null=True,
+ blank=True,
+ help_text="Maximum score per case used for normalisation. Defaults to 10 when not set.",
+ )
+
+ # -----------------------------------------------------------------------
+ # Case question template
+ # When set, this JSON schema is used as a default for all CaseDetail entries
+ # in the collection that do not have their own question_schema.
+ # -----------------------------------------------------------------------
+ case_question_template = models.JSONField(
+ null=True,
+ blank=True,
+ help_text=(
+ "JSON schema applied as a default question template for all cases in this collection. "
+ "Individual cases can override via CaseDetail.question_schema."
+ ),
+ )
+ auto_apply_question_template = models.BooleanField(
+ default=False,
+ help_text=(
+ "If enabled, the case_question_template is automatically applied to new cases "
+ "added to this collection that do not have an existing question_schema."
+ ),
+ )
+
def get_app_name(self):
return "atlas"
@@ -1546,6 +1587,38 @@ class CaseCollection(ExamOrCollectionGenericBase):
else:
return cases[new_index]
+ def apply_question_template_to_cases(self, overwrite_existing=False):
+ """
+ Apply case_question_template to all CaseDetail entries in this collection.
+
+ Args:
+ overwrite_existing: If True, overwrite existing question_schema on CaseDetails.
+ If False (default), only apply to cases without a schema.
+
+ Returns:
+ int: Number of CaseDetail entries updated.
+ """
+ if not self.case_question_template:
+ return 0
+
+ casedetails = self.casedetail_set.all()
+ updated = 0
+ for cd in casedetails:
+ if overwrite_existing or not cd.question_schema:
+ cd.question_schema = self.case_question_template
+ cd.save(update_fields=["question_schema"])
+ updated += 1
+ return updated
+
+ def get_effective_question_schema(self, casedetail):
+ """
+ Return the effective question schema for a CaseDetail.
+ Uses the individual CaseDetail schema if set, otherwise falls back to the collection template.
+ """
+ if casedetail.question_schema:
+ return casedetail.question_schema
+ return self.case_question_template
+
def get_index_of_case(self, case, case_count=False):
cases = list(self.get_cases())
diff --git a/atlas/templates/atlas/research_participant_entry.html b/atlas/templates/atlas/research_participant_entry.html
new file mode 100644
index 00000000..a4dd10ad
--- /dev/null
+++ b/atlas/templates/atlas/research_participant_entry.html
@@ -0,0 +1,32 @@
+{% extends "atlas/base.html" %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+
{{ study.name }}
+Participant ID: {{ participant.pseudo_id }}
+
+{% if assigned_packet and start_url %}
+
+
Assignment Complete
+
You have been assigned to packet: {{ participant.assigned_arm.name }}.
+
Mark scheme: {{ participant.assigned_arm.marking_scheme_name|default:'Default collection scoring' }}
+
Start Packet
+
+{% else %}
+
+
Consent and Details
+
Complete this form to begin. Assignment to packet is performed automatically when you submit.
+
+
+{% endif %}
+
+{% if study.description %}
+
+ {{ study.description|linebreaks }}
+
+{% endif %}
+{% endblock %}
diff --git a/atlas/templates/atlas/research_study_arm_form.html b/atlas/templates/atlas/research_study_arm_form.html
new file mode 100644
index 00000000..f3421a7c
--- /dev/null
+++ b/atlas/templates/atlas/research_study_arm_form.html
@@ -0,0 +1,16 @@
+{% extends "atlas/base.html" %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+Update Packet: {{ arm.name }}
+Study: {{ study.name }}
+
+
+{% endblock %}
diff --git a/atlas/templates/atlas/research_study_detail.html b/atlas/templates/atlas/research_study_detail.html
new file mode 100644
index 00000000..8fa6ce16
--- /dev/null
+++ b/atlas/templates/atlas/research_study_detail.html
@@ -0,0 +1,96 @@
+{% extends "atlas/base.html" %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+{{ study.name }}
+Slug: {{ study.slug }}
+
+
+
+
+
Participant URL
+
Append the pseudo-anonymised ID to this base URL:
+
{{ request.scheme }}://{{ request.get_host }}{% url 'atlas:research_participant_entry' study.slug 'PSEUDO_ID' %}
+
+
+
+
+
+
Packets
+ {% if arm_rows %}
+
+
+
+ | Name |
+ Collection |
+ Assigned |
+ Mark Scheme |
+ |
+
+
+
+ {% for arm, assigned_count in arm_rows %}
+
+ | {{ arm.name }} |
+ {{ arm.packet.name }} |
+ {{ assigned_count }} |
+ {{ arm.marking_scheme_name|default:'Default' }} |
+ Edit |
+
+ {% endfor %}
+
+
+ {% else %}
+
No packets configured. Add at least one packet before sharing participant links.
+ {% endif %}
+
+
+
+
+
+
+
+
Participants ({{ participants|length }})
+ {% if participants %}
+
+
+
+
+ | Pseudo ID |
+ Group |
+ Assigned Packet |
+ CID |
+ Assigned At |
+
+
+
+ {% for participant in participants %}
+
+ | {{ participant.pseudo_id }} |
+ {{ participant.candidate_group|default:'-' }} |
+ {% if participant.assigned_arm %}{{ participant.assigned_arm.name }}{% else %}-{% endif %} |
+ {% if participant.cid_user %}{{ participant.cid_user.cid }}{% else %}-{% endif %} |
+ {{ participant.assigned_at|default:'-' }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No participants yet.
+ {% endif %}
+
+{% endblock %}
diff --git a/atlas/templates/atlas/research_study_form.html b/atlas/templates/atlas/research_study_form.html
new file mode 100644
index 00000000..745f7048
--- /dev/null
+++ b/atlas/templates/atlas/research_study_form.html
@@ -0,0 +1,16 @@
+{% extends "atlas/base.html" %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+{% if is_create %}Create Research Study{% else %}Update Research Study{% endif %}
+Configure randomisation and participant intake options. Packets are added on the study detail page.
+
+
+{% endblock %}
diff --git a/atlas/templates/atlas/research_study_list.html b/atlas/templates/atlas/research_study_list.html
new file mode 100644
index 00000000..de46148f
--- /dev/null
+++ b/atlas/templates/atlas/research_study_list.html
@@ -0,0 +1,43 @@
+{% extends "atlas/base.html" %}
+
+{% block content %}
+Research Studies
+Create and manage study packets that randomise participants to Atlas collections.
+
+
+
+{% if studies %}
+
+
+
+ | Name |
+ Slug |
+ Status |
+ Randomisation |
+ |
+
+
+
+ {% for study in studies %}
+
+ | {{ study.name }} |
+ {{ study.slug }} |
+
+ {% if study.active %}
+ Active
+ {% else %}
+ Inactive
+ {% endif %}
+ |
+ {{ study.get_randomisation_mode_display }} |
+ Open |
+
+ {% endfor %}
+
+
+{% else %}
+No studies have been created yet.
+{% endif %}
+{% endblock %}
diff --git a/atlas/urls.py b/atlas/urls.py
index 4c6415aa..2bc13a22 100755
--- a/atlas/urls.py
+++ b/atlas/urls.py
@@ -99,6 +99,11 @@ urlpatterns = [
views.collection_sync_prerequisite_users,
name="collection_sync_prerequisite_users",
),
+ path(
+ "collection//apply_question_template",
+ views.collection_apply_question_template,
+ name="collection_apply_question_template",
+ ),
path("collection//authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
path(
"collection//toggle_results_published",
diff --git a/atlas/views.py b/atlas/views.py
index 9edbc75c..ea13ae48 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -1,5 +1,7 @@
import json
import pprint
+import csv
+import random
from typing import Any
from dal import autocomplete
from django.db.models.base import Model as Model
@@ -10,6 +12,7 @@ from django.db import transaction
from django.shortcuts import render, get_object_or_404, redirect
from django import forms
from django.utils import timezone
+from django.utils.crypto import get_random_string
from django.utils.html import escape
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_http_methods
@@ -55,7 +58,7 @@ import os
from django.core.files.base import ContentFile
from django.core.exceptions import PermissionDenied
-from generic.models import CidUser, CidUserExam, CimarCase, Site
+from generic.models import CidUser, CidUserExam, CimarCase, Site, get_next_cid
from atlas.helpers import get_cases_available_to_user
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
@@ -128,7 +131,7 @@ from .models import (
UncategorisedDicom,
UserReportAnswer,
PrerequisiteRequired
- , APIToken
+ , APIToken,
)
from .models import Procedure
from .models import NormalCase
@@ -7547,7 +7550,9 @@ def collection_scores_cid(request, pk):
# ignore scores of 0 for stats
user_scores_list = [i for i in user_scores.values() if i > 0]
- max_score = len(cases)
+ # Use collection's case_question_max_score if set, otherwise default to 10
+ case_max = collection.case_question_max_score or 10
+ max_score = len(cases) * case_max
if len(user_scores_list) < 1:
mean = 0
@@ -8015,6 +8020,31 @@ def collection_question_schemas(request, exam_id: int):
)
+@user_is_collection_author_or_atlas_editor
+def collection_apply_question_template(request, pk: int):
+ """Apply the collection's case_question_template to all CaseDetail entries.
+
+ Scope: Collection author / atlas_editor. HTMX endpoint.
+ Accepts optional 'overwrite' query param to overwrite existing schemas.
+ """
+ if not request.htmx:
+ raise Http404("Invalid request")
+
+ collection = get_object_or_404(CaseCollection, pk=pk)
+
+ if not collection.case_question_template:
+ return HttpResponse(
+ 'No question template set on this collection.
'
+ )
+
+ overwrite = request.GET.get("overwrite", "false").lower() == "true"
+ updated_count = collection.apply_question_template_to_cases(overwrite_existing=overwrite)
+
+ return HttpResponse(
+ f'Applied question template to {updated_count} case(s).
'
+ )
+
+
@user_is_collection_author_or_atlas_editor
def collection_sync_prerequisite_users(request, pk: int):
"""Copy valid cid_users and user_users from all prerequisite collections into this collection.
diff --git a/docs/research_platform_implementation.html b/docs/research_platform_implementation.html
new file mode 100644
index 00000000..6a698945
--- /dev/null
+++ b/docs/research_platform_implementation.html
@@ -0,0 +1,172 @@
+
+
+
+
+
+ Research Platform Extension - Implementation Summary
+
+
+
+
+
+
Research Platform Extension
+
Generated: 12 May 2026
+
+ The Atlas app was extended to support multi-study research workflows that randomise participants
+ to packeted case collections while reusing existing collection-taking, timing, marking, and attempt models.
+
+
+ Randomisation on site
+ Pseudo-ID workflow
+ Per-packet marking config
+ CSV/JSON export
+
+
+
+
+
Implemented Data Model
+
+ ResearchStudy: study container with slug, active flag, demographic collection toggle, and randomisation mode.
+ ResearchStudyArm: packet definition linked to existing CaseCollection, including weighting and marking scheme metadata.
+ ResearchParticipant: pseudo-ID keyed participant with demographic fields, consent flag, assigned arm, and generated CID credential link.
+
+
Migration generated: atlas/migrations/0103_researchstudy_researchstudyarm_researchparticipant.py.
+
+
+
+
Participant Flow
+
+ - Participant visits:
/atlas/research/run/<study_slug>/<pseudo_id>/.
+ - Site captures consent and simple demographics (configurable per study).
+ - Participant is randomised to a packet (study arm) by configured strategy.
+ - A dedicated
CidUser credential is created and attached to the participant if needed.
+ - Assigned packet
CaseCollection is launched via existing candidate collection routes.
+
+
This reuses current Atlas taking/timing/marking behavior and avoids changes to candidate-facing scoring internals.
+
+
+
+
Randomisation Logic
+
+
+ | Mode | Behavior |
+
+
+ | Completely random | Weighted random choice over active arms using allocation_weight. |
+ | Balanced (equal-fill) | Assigns to least-filled active arm; tie broken randomly. |
+ | Balanced within group | If enabled, balancing is applied within candidate_group strata. |
+
+
+
+
+
+
Marking and Scoring
+
+ - Existing answer models remain in use:
CidReportAnswer / UserReportAnswer.
+ - Existing collection mark views remain functional for non-technical markers.
+ - Per-packet configuration fields added on study arm:
+
+ marking_scheme_name
+ marking_guidance
+ scoring_mode and max_case_score for normalised exports
+
+
+
+
Current marker UI itself is unchanged; packet-specific configuration is reflected in study management and exports.
+
+
+
+
Management and Export
+
+
+
Management Views
+
+ /atlas/research/ list studies
+ /atlas/research/create/ create study
+ /atlas/research/<pk>/ study detail + packets + participants
+ /atlas/research/<pk>/update/ update study
+ /atlas/research/<pk>/arm/<arm_id>/update/ update packet
+
+
+
+
Exports
+
+ /atlas/research/<pk>/export.csv
+ /atlas/research/<pk>/export.json
+
+
Export rows include demographics, assignment metadata, credential ID, attempt status, and raw/normalised scores.
+
+
+
+
+
+
Files Added/Updated
+
+ - Updated:
atlas/models.py, atlas/forms.py, atlas/views.py, atlas/urls.py, atlas/admin.py
+ - Added templates:
+
+ atlas/templates/atlas/research_study_list.html
+ atlas/templates/atlas/research_study_form.html
+ atlas/templates/atlas/research_study_detail.html
+ atlas/templates/atlas/research_study_arm_form.html
+ atlas/templates/atlas/research_participant_entry.html
+
+
+ - Added migration:
atlas/migrations/0103_researchstudy_researchstudyarm_researchparticipant.py
+
+
+
+
+
Compatibility Notes
+
+ - No existing collection, case, or answer model behavior was removed.
+ - Randomisation and participant routing are additive and isolated to new research URLs.
+ - Author/editor permission pattern is respected for study management views.
+
+
+
+
+
diff --git a/rad/settings.py b/rad/settings.py
index 0a6e1941..12772b8b 100644
--- a/rad/settings.py
+++ b/rad/settings.py
@@ -52,6 +52,7 @@ INSTALLED_APPS = [
"sbas",
"wally",
"atlas",
+ "research",
"rcr",
"rota",
"oef",
diff --git a/rad/urls.py b/rad/urls.py
index 42499b7b..f1a6805a 100644
--- a/rad/urls.py
+++ b/rad/urls.py
@@ -50,6 +50,7 @@ urlpatterns = [
path("sbas/", include("sbas.urls"), name="sbas"),
path("generic/", include("generic.urls"), name="generic"),
path("atlas/", include("atlas.urls"), name="atlas"),
+ path("research/", include("research.urls"), name="research"),
path("rcr/", include("rcr.urls"), name="rcr"),
path("rota/", include("rota.urls"), name="rota"),
path("oef/", include("oef.urls"), name="oef"),
diff --git a/research/__init__.py b/research/__init__.py
new file mode 100644
index 00000000..0c5ad5b9
--- /dev/null
+++ b/research/__init__.py
@@ -0,0 +1 @@
+default_app_config = 'research.apps.ResearchConfig'
diff --git a/research/admin.py b/research/admin.py
new file mode 100644
index 00000000..535d9de9
--- /dev/null
+++ b/research/admin.py
@@ -0,0 +1,116 @@
+from django.contrib import admin
+from django.utils.html import format_html
+from research.models import ResearchStudy, ResearchStudyArm, ResearchParticipant
+
+
+@admin.register(ResearchStudy)
+class ResearchStudyAdmin(admin.ModelAdmin):
+ list_display = ['name', 'slug', 'active', 'generation_mode', 'randomisation_mode', 'created_at']
+ list_filter = ['active', 'generation_mode', 'randomisation_mode', 'created_at']
+ search_fields = ['name', 'slug', 'description']
+ readonly_fields = ['created_at', 'updated_at']
+ fieldsets = (
+ ('Study Information', {
+ 'fields': ('name', 'slug', 'description', 'active')
+ }),
+ ('Participant Creation', {
+ 'fields': ('generation_mode', 'collect_demographics'),
+ 'description': 'Configure how participants are created and what data is collected.'
+ }),
+ ('Randomisation Strategy', {
+ 'fields': ('randomisation_mode', 'balance_within_candidate_group', 'allocation_targets'),
+ 'description': 'Configure how participants are assigned to arms.'
+ }),
+ ('Authors', {
+ 'fields': ('author',),
+ }),
+ ('Timestamps', {
+ 'fields': ('created_at', 'updated_at'),
+ 'classes': ('collapse',)
+ }),
+ )
+
+
+@admin.register(ResearchStudyArm)
+class ResearchStudyArmAdmin(admin.ModelAdmin):
+ list_display = ['name', 'study', 'packet', 'active', 'sort_order', 'order_sequence']
+ list_filter = ['study', 'active', 'order_sequence']
+ search_fields = ['name', 'study__name', 'packet__name']
+ fieldsets = (
+ ('Arm Information', {
+ 'fields': ('study', 'name', 'packet', 'active', 'sort_order')
+ }),
+ ('Randomisation', {
+ 'fields': ('allocation_weight',),
+ 'description': 'Used for completely-random allocation.'
+ }),
+ ('Ordering & Prerequisites', {
+ 'fields': ('order_sequence', 'required_previous_arm'),
+ 'description': 'Configure sequential completion requirements.'
+ }),
+ )
+
+ def get_form(self, request, obj=None, **kwargs):
+ """Pre-filter related fields based on study."""
+ form = super().get_form(request, obj, **kwargs)
+ if obj and hasattr(obj, 'study'):
+ form.base_fields['required_previous_arm'].queryset = ResearchStudyArm.objects.filter(
+ study=obj.study
+ ).exclude(pk=obj.pk)
+ return form
+
+
+@admin.register(ResearchParticipant)
+class ResearchParticipantAdmin(admin.ModelAdmin):
+ list_display = ['pseudo_id', 'study', 'assigned_arm', 'cid_user_display', 'consented', 'assigned_at']
+ list_filter = ['study', 'assigned_arm', 'consented', 'created_at']
+ search_fields = ['pseudo_id', 'study__slug', 'cid_user__cid', 'email']
+ readonly_fields = ['created_at', 'updated_at', 'cid_user_info']
+ fieldsets = (
+ ('Study & ID', {
+ 'fields': ('study', 'pseudo_id')
+ }),
+ ('CID User', {
+ 'fields': ('cid_user', 'cid_user_info'),
+ 'description': 'Associated CID user for study access.'
+ }),
+ ('Demographics', {
+ 'fields': ('candidate_group', 'age_band', 'sex', 'training_grade', 'years_experience', 'email'),
+ }),
+ ('Assignment', {
+ 'fields': ('assigned_arm', 'assignment_method', 'assigned_at'),
+ }),
+ ('Consent & Status', {
+ 'fields': ('consented', 'completed_arms'),
+ }),
+ ('User Link', {
+ 'fields': ('user_user',),
+ 'classes': ('collapse',)
+ }),
+ ('Timestamps', {
+ 'fields': ('created_at', 'updated_at'),
+ 'classes': ('collapse',)
+ }),
+ )
+
+ def cid_user_display(self, obj):
+ """Display CID number with link."""
+ if obj.cid_user:
+ return format_html(
+ '{}',
+ obj.cid_user.cid
+ )
+ return '-'
+ cid_user_display.short_description = 'CID'
+
+ def cid_user_info(self, obj):
+ """Display full CID user info."""
+ if obj.cid_user:
+ return format_html(
+ 'CID: {}
Passcode: {}
Name: {}',
+ obj.cid_user.cid,
+ obj.cid_user.passcode,
+ obj.cid_user.name
+ )
+ return 'Not yet assigned'
+ cid_user_info.short_description = 'CID User Information'
diff --git a/research/apps.py b/research/apps.py
new file mode 100644
index 00000000..1aa4c822
--- /dev/null
+++ b/research/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class ResearchConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'research'
+ verbose_name = 'Research Studies'
diff --git a/research/forms.py b/research/forms.py
new file mode 100644
index 00000000..cd352af8
--- /dev/null
+++ b/research/forms.py
@@ -0,0 +1,232 @@
+from django import forms
+from django.forms import (
+ ModelForm,
+ ValidationError,
+ CheckboxInput,
+ TextInput,
+)
+from crispy_forms.helper import FormHelper
+from crispy_forms.layout import (
+ Layout,
+ Fieldset,
+ Div,
+ Row,
+ Column,
+ Submit,
+ HTML,
+)
+
+from research.models import ResearchStudy, ResearchStudyArm, ResearchParticipant
+
+
+class ResearchStudyForm(ModelForm):
+ """Form for creating/editing research studies."""
+
+ class Meta:
+ model = ResearchStudy
+ fields = [
+ "name",
+ "slug",
+ "description",
+ "active",
+ "randomisation_mode",
+ "generation_mode",
+ "balance_within_candidate_group",
+ "collect_demographics",
+ "allocation_targets",
+ ]
+ widgets = {
+ "description": forms.Textarea(attrs={"rows": 4}),
+ "allocation_targets": forms.Textarea(attrs={"rows": 4, "placeholder": '{"arm_1": 30, "arm_2": 30}'}),
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = FormHelper()
+ self.helper.form_method = 'post'
+ self.helper.layout = Layout(
+ Fieldset(
+ 'Study Details',
+ 'name',
+ 'slug',
+ 'description',
+ 'active',
+ ),
+ Fieldset(
+ 'Participant Creation',
+ 'generation_mode',
+ 'collect_demographics',
+ ),
+ Fieldset(
+ 'Randomisation Strategy',
+ 'randomisation_mode',
+ 'balance_within_candidate_group',
+ Row(
+ Column('allocation_targets', css_class='col-md-12'),
+ HTML('For target-based allocation, provide JSON like: {"arm_id": count, ...}'),
+ ),
+ ),
+ Submit('submit', 'Save Study'),
+ )
+
+
+class ResearchStudyArmForm(ModelForm):
+ """Form for creating/editing study arms (packets)."""
+
+ class Meta:
+ model = ResearchStudyArm
+ fields = [
+ "name",
+ "packet",
+ "active",
+ "allocation_weight",
+ "sort_order",
+ "order_sequence",
+ "required_previous_arm",
+ ]
+
+ def __init__(self, study=None, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = FormHelper()
+ self.helper.form_method = 'post'
+
+ # Filter previous_arm choices to arms in the same study
+ if study:
+ self.fields['required_previous_arm'].queryset = ResearchStudyArm.objects.filter(
+ study=study
+ ).exclude(pk=self.instance.pk if self.instance.pk else None)
+ else:
+ self.fields['required_previous_arm'].queryset = ResearchStudyArm.objects.none()
+
+ self.helper.layout = Layout(
+ Fieldset(
+ 'Arm Configuration',
+ 'name',
+ 'packet',
+ 'active',
+ 'sort_order',
+ ),
+ Fieldset(
+ 'Randomisation',
+ 'allocation_weight',
+ ),
+ Fieldset(
+ 'Ordering & Prerequisites',
+ 'order_sequence',
+ 'required_previous_arm',
+ HTML('Set order_sequence to enforce ordering. Set required_previous_arm to enforce prerequisites.'),
+ ),
+ Submit('submit', 'Save Arm'),
+ )
+
+
+class ResearchParticipantIntakeForm(ModelForm):
+ """Form for participant intake (demographics and consent)."""
+
+ class Meta:
+ model = ResearchParticipant
+ fields = [
+ "candidate_group",
+ "age_band",
+ "sex",
+ "training_grade",
+ "years_experience",
+ "email",
+ "consented",
+ ]
+ widgets = {
+ "candidate_group": TextInput(attrs={"placeholder": "e.g. Group A"}),
+ "age_band": TextInput(attrs={"placeholder": "e.g. 30-39"}),
+ "sex": TextInput(attrs={"placeholder": "Optional"}),
+ "training_grade": TextInput(attrs={"placeholder": "e.g. ST4"}),
+ "years_experience": TextInput(attrs={"placeholder": "e.g. 5"}),
+ "email": TextInput(attrs={"placeholder": "optional@example.com", "type": "email"}),
+ "consented": CheckboxInput(),
+ }
+
+ def __init__(self, *args, collect_demographics=True, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.collect_demographics = collect_demographics
+
+ if not collect_demographics:
+ # Make demographic fields optional if not collecting
+ for f in [
+ "candidate_group",
+ "age_band",
+ "sex",
+ "training_grade",
+ "years_experience",
+ ]:
+ self.fields[f].required = False
+ else:
+ # Make candidate_group required if collecting (useful for balancing)
+ self.fields["candidate_group"].required = True
+
+ self.helper = FormHelper()
+ self.helper.form_method = 'post'
+ self.helper.layout = Layout(
+ Fieldset(
+ 'Demographics',
+ 'candidate_group',
+ 'age_band',
+ 'sex',
+ 'training_grade',
+ 'years_experience',
+ ),
+ Fieldset(
+ 'Contact & Consent',
+ 'email',
+ HTML('Provide an email address to reaccess your results later if you forget your passcode.
'),
+ 'consented',
+ HTML('Consent Required: You must consent to participate to continue.
'),
+ ),
+ Submit('submit', 'Start Study'),
+ )
+
+ def clean_consented(self):
+ """Require explicit consent."""
+ consented = self.cleaned_data.get("consented")
+ if not consented:
+ raise ValidationError("Consent is required to continue.")
+ return consented
+
+ def clean_email(self):
+ """Validate email format if provided."""
+ email = self.cleaned_data.get("email", "").strip()
+ if email:
+ # Basic email validation is done by EmailField, but we can add custom logic here
+ pass
+ return email
+
+
+class BulkParticipantGenerationForm(forms.Form):
+ """Form for bulk generating participants from CSV/JSON."""
+
+ UPLOAD_FORMAT_CHOICES = [
+ ('csv', 'CSV (pseudo_id, candidate_group, email)'),
+ ('json', 'JSON (array of objects)'),
+ ]
+
+ format = forms.ChoiceField(
+ choices=UPLOAD_FORMAT_CHOICES,
+ initial='csv',
+ help_text="Choose upload format",
+ )
+
+ file = forms.FileField(
+ help_text="CSV or JSON file with participant data",
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = FormHelper()
+ self.helper.form_method = 'post'
+ self.helper.layout = Layout(
+ Fieldset(
+ 'Bulk Generate Participants',
+ 'format',
+ 'file',
+ HTML('CSV format: pseudo_id,candidate_group,email
JSON format: [{"pseudo_id": "P001", "candidate_group": "A", "email": "..."}]'),
+ ),
+ Submit('submit', 'Generate Participants'),
+ )
diff --git a/research/migrations/0001_initial.py b/research/migrations/0001_initial.py
new file mode 100644
index 00000000..10acd3ad
--- /dev/null
+++ b/research/migrations/0001_initial.py
@@ -0,0 +1,95 @@
+# Generated by Django 6.0.1 on 2026-05-12 20:35
+
+import django.db.models.deletion
+import generic.mixins
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('atlas', '0103_casecollection_auto_apply_question_template_and_more'),
+ ('generic', '0032_ciduserexam_shared_with_users'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ResearchStudy',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=255)),
+ ('slug', models.SlugField(max_length=100, unique=True)),
+ ('description', models.TextField(blank=True)),
+ ('active', models.BooleanField(default=True)),
+ ('randomisation_mode', models.CharField(choices=[('RANDOM', 'Completely random'), ('BALANCED', 'Balanced (equal-fill)'), ('BALANCED_GROUP', 'Balanced within candidate group'), ('TARGET_BASED', 'Target-based allocation')], default='BALANCED', help_text='How participants are assigned to packets.', max_length=20)),
+ ('generation_mode', models.CharField(choices=[('AUTO', 'Auto-create on first access'), ('BULK', 'Bulk generation only')], default='AUTO', help_text='How participants are created for this study.', max_length=20)),
+ ('balance_within_candidate_group', models.BooleanField(default=True, help_text='If enabled, balanced randomisation is performed independently per candidate group.')),
+ ('collect_demographics', models.BooleanField(default=True, help_text='If enabled, participants are asked for simple demographic fields before starting.')),
+ ('allocation_targets', models.JSONField(blank=True, default=dict, help_text="For target-based allocation: maps arm_id or group_name to target count. E.g. {'arm_1': 30, 'arm_2': 30}")),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('author', models.ManyToManyField(blank=True, help_text='Users allowed to manage this study.', related_name='research_studies', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Research Study',
+ 'verbose_name_plural': 'Research Studies',
+ 'ordering': ('-created_at',),
+ },
+ bases=(models.Model, generic.mixins.AuthorMixin),
+ ),
+ migrations.CreateModel(
+ name='ResearchStudyArm',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100)),
+ ('active', models.BooleanField(default=True)),
+ ('allocation_weight', models.PositiveIntegerField(default=1, help_text='Relative weighting used for completely-random assignment.')),
+ ('sort_order', models.PositiveIntegerField(default=100)),
+ ('order_sequence', models.PositiveIntegerField(default=0, help_text='Order in which this arm should be completed (0 = no ordering). Used with prerequisite support.')),
+ ('packet', models.ForeignKey(help_text='CaseCollection used as this packet.', on_delete=django.db.models.deletion.PROTECT, related_name='research_arms', to='atlas.casecollection')),
+ ('required_previous_arm', models.ForeignKey(blank=True, help_text='If set, participant must complete this arm before the dependent arm.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_arms', to='research.researchstudyarm')),
+ ('study', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='arms', to='research.researchstudy')),
+ ],
+ options={
+ 'verbose_name': 'Research Study Arm',
+ 'verbose_name_plural': 'Research Study Arms',
+ 'ordering': ('sort_order', 'id'),
+ 'unique_together': {('study', 'name')},
+ },
+ ),
+ migrations.CreateModel(
+ name='ResearchParticipant',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('pseudo_id', models.CharField(help_text='Pseudo-anonymised ID used in participant URL.', max_length=100)),
+ ('candidate_group', models.CharField(blank=True, max_length=100)),
+ ('age_band', models.CharField(blank=True, max_length=100)),
+ ('sex', models.CharField(blank=True, max_length=50)),
+ ('training_grade', models.CharField(blank=True, max_length=100)),
+ ('years_experience', models.CharField(blank=True, max_length=50)),
+ ('demographics_extra', models.JSONField(blank=True, default=dict)),
+ ('email', models.EmailField(blank=True, help_text='Email address for participant result reaccess.', max_length=254)),
+ ('consented', models.BooleanField(default=False)),
+ ('assignment_method', models.CharField(blank=True, choices=[('RANDOM', 'Completely random'), ('BALANCED', 'Balanced'), ('BALANCED_GROUP', 'Balanced within group'), ('TARGET_BASED', 'Target-based'), ('MANUAL', 'Manual')], max_length=20)),
+ ('assigned_at', models.DateTimeField(blank=True, null=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('cid_user', models.OneToOneField(blank=True, help_text='CidUser for this participant (1-1 mapping; assigned at intake).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='research_participant', to='generic.ciduser')),
+ ('user_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='research_participants', to=settings.AUTH_USER_MODEL)),
+ ('study', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='research.researchstudy')),
+ ('assigned_arm', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='participants', to='research.researchstudyarm')),
+ ('completed_arms', models.ManyToManyField(blank=True, help_text='Arms this participant has completed.', related_name='completed_by_participants', to='research.researchstudyarm')),
+ ],
+ options={
+ 'verbose_name': 'Research Participant',
+ 'verbose_name_plural': 'Research Participants',
+ 'ordering': ('-created_at',),
+ 'constraints': [models.UniqueConstraint(fields=('study', 'cid_user'), name='unique_cid_per_study')],
+ 'unique_together': {('study', 'pseudo_id')},
+ },
+ ),
+ ]
diff --git a/research/migrations/__init__.py b/research/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/research/models.py b/research/models.py
new file mode 100644
index 00000000..70691f72
--- /dev/null
+++ b/research/models.py
@@ -0,0 +1,231 @@
+from django.db import models
+from django.conf import settings
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+from django.core.exceptions import ValidationError
+from generic.mixins import AuthorMixin
+from generic.models import CidUser
+from atlas.models import CaseCollection
+from loguru import logger
+
+
+class ResearchStudy(models.Model, AuthorMixin):
+ """Container for a research trial that assigns participants to collection packets."""
+
+ class RandomisationMode(models.TextChoices):
+ COMPLETELY_RANDOM = "RANDOM", _("Completely random")
+ BALANCED = "BALANCED", _("Balanced (equal-fill)")
+ BALANCED_WITHIN_GROUP = "BALANCED_GROUP", _("Balanced within candidate group")
+ TARGET_BASED = "TARGET_BASED", _("Target-based allocation")
+
+ class GenerationMode(models.TextChoices):
+ AUTO_ON_ACCESS = "AUTO", _("Auto-create on first access")
+ BULK_ONLY = "BULK", _("Bulk generation only")
+
+ name = models.CharField(max_length=255)
+ slug = models.SlugField(max_length=100, unique=True)
+ description = models.TextField(blank=True)
+
+ active = models.BooleanField(default=True)
+
+ randomisation_mode = models.CharField(
+ max_length=20,
+ choices=RandomisationMode.choices,
+ default=RandomisationMode.BALANCED,
+ help_text="How participants are assigned to packets.",
+ )
+
+ generation_mode = models.CharField(
+ max_length=20,
+ choices=GenerationMode.choices,
+ default=GenerationMode.AUTO_ON_ACCESS,
+ help_text="How participants are created for this study.",
+ )
+
+ balance_within_candidate_group = models.BooleanField(
+ default=True,
+ help_text="If enabled, balanced randomisation is performed independently per candidate group.",
+ )
+
+ collect_demographics = models.BooleanField(
+ default=True,
+ help_text="If enabled, participants are asked for simple demographic fields before starting.",
+ )
+
+ allocation_targets = models.JSONField(
+ default=dict,
+ blank=True,
+ help_text="For target-based allocation: maps arm_id or group_name to target count. E.g. {'arm_1': 30, 'arm_2': 30}",
+ )
+
+ author = models.ManyToManyField(
+ settings.AUTH_USER_MODEL,
+ blank=True,
+ help_text="Users allowed to manage this study.",
+ related_name="research_studies",
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ("-created_at",)
+ verbose_name = "Research Study"
+ verbose_name_plural = "Research Studies"
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse("research:study_detail", kwargs={"pk": self.pk})
+
+
+class ResearchStudyArm(models.Model):
+ """One packet/arm in a study linked to a CaseCollection."""
+
+ study = models.ForeignKey(
+ ResearchStudy,
+ on_delete=models.CASCADE,
+ related_name="arms",
+ )
+ name = models.CharField(max_length=100)
+ packet = models.ForeignKey(
+ CaseCollection,
+ on_delete=models.PROTECT,
+ related_name="research_arms",
+ help_text="CaseCollection used as this packet.",
+ )
+ active = models.BooleanField(default=True)
+ allocation_weight = models.PositiveIntegerField(
+ default=1,
+ help_text="Relative weighting used for completely-random assignment.",
+ )
+ sort_order = models.PositiveIntegerField(default=100)
+
+ # Support for ordered packets with prerequisites
+ order_sequence = models.PositiveIntegerField(
+ default=0,
+ help_text="Order in which this arm should be completed (0 = no ordering). Used with prerequisite support.",
+ )
+ required_previous_arm = models.ForeignKey(
+ "self",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="dependent_arms",
+ help_text="If set, participant must complete this arm before the dependent arm.",
+ )
+
+ class Meta:
+ ordering = ("sort_order", "id")
+ unique_together = ("study", "name")
+ verbose_name = "Research Study Arm"
+ verbose_name_plural = "Research Study Arms"
+
+ def __str__(self):
+ return f"{self.study.name}: {self.name}"
+
+
+class ResearchParticipant(models.Model):
+ """Pseudo-anonymised participant record for one study with 1-1 CidUser mapping."""
+
+ class AssignmentMethod(models.TextChoices):
+ COMPLETELY_RANDOM = "RANDOM", _("Completely random")
+ BALANCED = "BALANCED", _("Balanced")
+ BALANCED_WITHIN_GROUP = "BALANCED_GROUP", _("Balanced within group")
+ TARGET_BASED = "TARGET_BASED", _("Target-based")
+ MANUAL = "MANUAL", _("Manual")
+
+ study = models.ForeignKey(
+ ResearchStudy,
+ on_delete=models.CASCADE,
+ related_name="participants",
+ )
+ pseudo_id = models.CharField(
+ max_length=100,
+ help_text="Pseudo-anonymised ID used in participant URL.",
+ )
+
+ # 1-1 mapping to CidUser per study. Nullable until participant completes intake.
+ cid_user = models.OneToOneField(
+ CidUser,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="research_participant",
+ help_text="CidUser for this participant (1-1 mapping; assigned at intake).",
+ )
+
+ # Optional link to authenticated user (if participant logged in)
+ user_user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="research_participants",
+ )
+
+ # Demographics captured at intake
+ candidate_group = models.CharField(max_length=100, blank=True)
+ age_band = models.CharField(max_length=100, blank=True)
+ sex = models.CharField(max_length=50, blank=True)
+ training_grade = models.CharField(max_length=100, blank=True)
+ years_experience = models.CharField(max_length=50, blank=True)
+ demographics_extra = models.JSONField(default=dict, blank=True)
+
+ # Email for result reaccess (stored on CidUser, but captured here for convenience)
+ email = models.EmailField(
+ blank=True,
+ help_text="Email address for participant result reaccess.",
+ )
+
+ consented = models.BooleanField(default=False)
+
+ # Arm assignment
+ assigned_arm = models.ForeignKey(
+ ResearchStudyArm,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="participants",
+ )
+ assignment_method = models.CharField(
+ max_length=20,
+ choices=AssignmentMethod.choices,
+ blank=True,
+ )
+ assigned_at = models.DateTimeField(null=True, blank=True)
+
+ # Completion tracking
+ completed_arms = models.ManyToManyField(
+ ResearchStudyArm,
+ blank=True,
+ related_name="completed_by_participants",
+ help_text="Arms this participant has completed.",
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ unique_together = ("study", "pseudo_id")
+ # Ensure CidUser is unique per study (1-1 mapping)
+ constraints = [
+ models.UniqueConstraint(
+ fields=['study', 'cid_user'],
+ name='unique_cid_per_study'
+ )
+ ]
+ ordering = ("-created_at",)
+ verbose_name = "Research Participant"
+ verbose_name_plural = "Research Participants"
+
+ def __str__(self):
+ return f"{self.study.slug}:{self.pseudo_id}"
+
+ def is_prerequisite_satisfied(self):
+ """Check if participant has completed prerequisite arm(s) for current assignment."""
+ if not self.assigned_arm or not self.assigned_arm.required_previous_arm:
+ return True
+ return self.completed_arms.filter(pk=self.assigned_arm.required_previous_arm.pk).exists()
diff --git a/research/templates/research/bulk_generate.html b/research/templates/research/bulk_generate.html
new file mode 100644
index 00000000..9cbfc122
--- /dev/null
+++ b/research/templates/research/bulk_generate.html
@@ -0,0 +1,51 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+
+
+
+
+
+
+
Upload Participants
+
+
+
+
+
+
+
Format Reference
+
CSV format (with header row):
+
pseudo_id,candidate_group,email
+P001,Group A,opt@example.com
+P002,Group B,
+
+
JSON format:
+
[
+ {"pseudo_id": "P001", "candidate_group": "A"},
+ {"pseudo_id": "P002", "candidate_group": "B", "email": "..."}
+]
+
+ Notes:
+
+ - Existing participants with the same pseudo_id will not be overwritten.
+ - CID credentials are generated when participants complete intake.
+ - email field is optional.
+
+
+
+
+
+
+{% endblock %}
diff --git a/research/templates/research/participant_entry.html b/research/templates/research/participant_entry.html
new file mode 100644
index 00000000..2f75d772
--- /dev/null
+++ b/research/templates/research/participant_entry.html
@@ -0,0 +1,61 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+
+
{{ study.name }}
+
Participant ID: {{ participant.pseudo_id }}
+
+ {% if study.description %}
+
+ {{ study.description|linebreaks }}
+
+ {% endif %}
+
+ {% if assigned_packet and start_url and participant.consented %}
+
+
+
Ready to Start
+
You have been assigned to: {{ participant.assigned_arm.name }}
+
Collection: {{ assigned_packet.name }}
+
+
+
+ Your Access Credentials
+ Keep note of these to reaccess your results later:
+ CID: {{ participant.cid_user.cid }} |
+ Passcode: {{ participant.cid_user.passcode }}
+ {% if participant.email %}
+
Results can also be accessed via email: {{ participant.email }}
+ {% endif %}
+
+
+
Start
+
+
+ {% elif participant.assigned_arm %}
+
+
+
Assignment Complete
+
Refreshing to load your credentials...
+
+
+ {% else %}
+
+
+
Participant Intake
+
+ Complete this form to be assigned to a study packet.
+ {% if study.collect_demographics %}
+ We collect anonymous demographic data to support analysis.
+ {% endif %}
+
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/research/templates/research/study_arm_form.html b/research/templates/research/study_arm_form.html
new file mode 100644
index 00000000..1c22f92c
--- /dev/null
+++ b/research/templates/research/study_arm_form.html
@@ -0,0 +1,45 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+
+
{% if arm %}Edit Arm: {{ arm.name }}{% else %}Add Arm{% endif %}
+
Study: {{ study.name }}
+
+
+
+ {% if arm and arm.packet %}
+
+
Collection Marking Configuration
+
+ Marking guidance and scheme names are now configured on the CaseCollection directly.
+ This allows the same configuration to be reused across multiple studies.
+
+
+ -
+ Marking Scheme: {{ arm.packet.marking_scheme_name|default:'(not set)' }}
+
+ -
+ Max Score Per Case: {{ arm.packet.case_question_max_score|default:'10 (default)' }}
+
+ -
+ Marking Guidance:
+ {{ arm.packet.marking_guidance|default:'(not set)' }}
+
+
+
+ Edit Collection Marking Config
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/research/templates/research/study_detail.html b/research/templates/research/study_detail.html
new file mode 100644
index 00000000..c1f61ac5
--- /dev/null
+++ b/research/templates/research/study_detail.html
@@ -0,0 +1,169 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+
+
+
+
{{ study.name }}
+
Slug: {{ study.slug }} •
+ {{ study.get_generation_mode_display }} •
+ {{ study.get_randomisation_mode_display }}
+
+
+
+
+
+
+
+
Participant Entry URL Template
+
Append the pseudo-anonymised ID to this base URL to share with participants:
+
{{ request.scheme }}://{{ request.get_host }}{% url 'research:participant_entry' study.slug 'PSEUDO_ID' %}
+
+
+ Participants use CID + passcode generated at intake to access their results.
+ {% if study.collect_demographics %}
+ Demographics collected at intake.
+ {% endif %}
+
+
+
+
+
+
+
+
+
Arms / Packets
+ {% if arm_rows %}
+
+
+
+ | Name |
+ Collection |
+ Assigned |
+ Prerequisite |
+ Mark Scheme |
+ |
+
+
+
+ {% for arm, assigned_count in arm_rows %}
+
+ | {{ arm.name }} |
+
+ {{ arm.packet.name }}
+ {% if arm.packet.marking_scheme_name %}
+ {{ arm.packet.marking_scheme_name }}
+ {% endif %}
+ |
+ {{ assigned_count }} |
+
+ {% if arm.required_previous_arm %}
+ {{ arm.required_previous_arm.name }}
+ {% else %}
+ -
+ {% endif %}
+ |
+ {{ arm.packet.marking_scheme_name|default:'-' }} |
+
+ Edit
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
No packets configured. Add at least one packet before sharing participant links.
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
Participants ({{ participants|length }})
+
+ {% if participants %}
+
+
+
+
+ | Pseudo ID |
+ Group |
+ Assigned Arm |
+ CID |
+ Email |
+ Consented |
+ Assigned At |
+
+
+
+ {% for participant in participants %}
+
+ {{ participant.pseudo_id }} |
+ {{ participant.candidate_group|default:'-' }} |
+
+ {% if participant.assigned_arm %}
+ {{ participant.assigned_arm.name }}
+ {% else %}
+ -
+ {% endif %}
+ |
+
+ {% if participant.cid_user %}
+ {{ participant.cid_user.cid }}
+ {% else %}
+ -
+ {% endif %}
+ |
+
+ {% if participant.email %}
+ {{ participant.email }}
+ {% else %}
+ -
+ {% endif %}
+ |
+
+ {% if participant.consented %}
+ Yes
+ {% else %}
+ No
+ {% endif %}
+ |
+ {{ participant.assigned_at|default:'-' }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No participants yet.
+ {% if study.generation_mode == 'BULK' %}
+ Use
Bulk Generate to add participants.
+ {% else %}
+ Participants will be created automatically when they access the participant URL.
+ {% endif %}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/research/templates/research/study_form.html b/research/templates/research/study_form.html
new file mode 100644
index 00000000..c34bbfed
--- /dev/null
+++ b/research/templates/research/study_form.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+{% load crispy_forms_tags %}
+
+{% block content %}
+
+
{% if is_create %}Create Research Study{% else %}Edit: {{ study.name }}{% endif %}
+
Configure randomisation and participant intake options. Packets are added on the study detail page.
+
+
+
+{% endblock %}
diff --git a/research/templates/research/study_list.html b/research/templates/research/study_list.html
new file mode 100644
index 00000000..6f3e4f84
--- /dev/null
+++ b/research/templates/research/study_list.html
@@ -0,0 +1,48 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
Research Studies
+
+ Create Study
+
+
Create and manage research study packets that randomise participants to Atlas case collections.
+
+ {% if studies %}
+
+
+
+ | Name |
+ Slug |
+ Status |
+ Randomisation |
+ Participants |
+ |
+
+
+
+ {% for study in studies %}
+
+ | {{ study.name }} |
+ {{ study.slug }} |
+
+ {% if study.active %}
+ Active
+ {% else %}
+ Inactive
+ {% endif %}
+ |
+ {{ study.get_randomisation_mode_display }} |
+ {{ study.participants.count }} |
+
+ Open
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
No studies have been created yet.
+ {% endif %}
+
+{% endblock %}
diff --git a/research/tests/__init__.py b/research/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/research/urls.py b/research/urls.py
new file mode 100644
index 00000000..9b85af29
--- /dev/null
+++ b/research/urls.py
@@ -0,0 +1,23 @@
+from django.urls import path
+from research import views
+
+app_name = "research"
+
+urlpatterns = [
+ # Study management
+ path("", views.study_list, name="study_list"),
+ path("create/", views.study_create, name="study_create"),
+ path("/", views.study_detail, name="study_detail"),
+ path("/update/", views.study_update, name="study_update"),
+ path("/arm//", views.study_arm_update, name="study_arm_update"),
+
+ # Exports
+ path("/export.csv", views.study_export_csv, name="study_export_csv"),
+ path("/export.json", views.study_export_json, name="study_export_json"),
+
+ # Bulk generation
+ path("/bulk-generate/", views.study_bulk_generate, name="study_bulk_generate"),
+
+ # Public participant entry (no login required)
+ path("run///", views.participant_entry, name="participant_entry"),
+]
diff --git a/research/views.py b/research/views.py
new file mode 100644
index 00000000..de3e48af
--- /dev/null
+++ b/research/views.py
@@ -0,0 +1,559 @@
+import csv
+import json
+import random
+from django.shortcuts import render, redirect, get_object_or_404
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.db import transaction
+from django.http import HttpResponse, JsonResponse, Http404
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.text import slugify
+from django.utils.crypto import get_random_string
+from django.contrib import messages
+
+from atlas.models import CaseCollection, CaseDetail, CidReportAnswer
+from generic.models import CidUser
+
+from research.models import ResearchStudy, ResearchStudyArm, ResearchParticipant
+from research.forms import (
+ ResearchStudyForm,
+ ResearchStudyArmForm,
+ ResearchParticipantIntakeForm,
+ BulkParticipantGenerationForm,
+)
+
+
+# ============================================================================
+# Utility Functions
+# ============================================================================
+
+def _user_can_manage_research_study(study: ResearchStudy, user) -> bool:
+ """Check if user can manage a research study."""
+ if not user.is_authenticated:
+ return False
+ if user.is_superuser:
+ return True
+ if user.groups.filter(name="atlas_editor").exists():
+ return True
+ return study.author.filter(pk=user.pk).exists()
+
+
+def _get_next_cid():
+ """Get the next available CID number."""
+ from django.db.models import Max
+ max_cid = CidUser.objects.aggregate(Max('cid'))['cid__max'] or 0
+ return max(max_cid + 1, 100000)
+
+
+def _choose_research_arm(study: ResearchStudy, candidate_group: str = "") -> ResearchStudyArm:
+ """Select an arm for participant assignment based on study's randomisation strategy."""
+ arms = list(study.arms.filter(active=True).select_related("packet").order_by("sort_order", "id"))
+ if not arms:
+ raise Http404("No active packets configured for this study")
+
+ randomisation_mode = study.randomisation_mode
+
+ # Completely random: weighted by allocation_weight
+ if randomisation_mode == ResearchStudy.RandomisationMode.COMPLETELY_RANDOM:
+ weights = [max(1, a.allocation_weight) for a in arms]
+ return random.choices(arms, weights=weights, k=1)[0]
+
+ # Target-based: allocate until arm reaches target, then move to next
+ if randomisation_mode == ResearchStudy.RandomisationMode.TARGET_BASED:
+ targets = study.allocation_targets or {}
+ eligible = []
+
+ for arm in arms:
+ target = targets.get(str(arm.pk), 0)
+ if target <= 0: # No limit
+ eligible.append(arm)
+ else:
+ current_count = ResearchParticipant.objects.filter(
+ study=study,
+ assigned_arm=arm,
+ ).count()
+ if current_count < target:
+ eligible.append(arm)
+
+ if not eligible:
+ # All targets met; fall back to balanced
+ participant_qs = ResearchParticipant.objects.filter(study=study, assigned_arm__isnull=False)
+ if study.balance_within_candidate_group and candidate_group:
+ participant_qs = participant_qs.filter(candidate_group=candidate_group)
+ counts = {arm.pk: participant_qs.filter(assigned_arm=arm).count() for arm in arms}
+ min_count = min(counts.values())
+ eligible = [arm for arm in arms if counts[arm.pk] == min_count]
+
+ return random.choice(eligible)
+
+ # Balanced within group: balance per candidate_group
+ if randomisation_mode == ResearchStudy.RandomisationMode.BALANCED_WITHIN_GROUP and candidate_group:
+ participant_qs = ResearchParticipant.objects.filter(
+ study=study,
+ assigned_arm__isnull=False,
+ candidate_group=candidate_group,
+ )
+ counts = {arm.pk: participant_qs.filter(assigned_arm=arm).count() for arm in arms}
+ min_count = min(counts.values())
+ eligible = [arm for arm in arms if counts[arm.pk] == min_count]
+ return random.choice(eligible)
+
+ # Balanced (default): global balance across all participants
+ participant_qs = ResearchParticipant.objects.filter(study=study, assigned_arm__isnull=False)
+ counts = {arm.pk: participant_qs.filter(assigned_arm=arm).count() for arm in arms}
+ min_count = min(counts.values())
+ eligible = [arm for arm in arms if counts[arm.pk] == min_count]
+ return random.choice(eligible)
+
+
+def _ensure_research_participant_cid(participant: ResearchParticipant) -> CidUser:
+ """Ensure participant has a CidUser (creates if needed); maintains 1-1 mapping."""
+ if participant.cid_user is not None:
+ return participant.cid_user
+
+ # Try to allocate a unique CID
+ created_cid = None
+ for _ in range(10):
+ try:
+ try:
+ next_cid = _get_next_cid()
+ except Exception:
+ next_cid = random.randint(100000, 999999)
+
+ created_cid = CidUser.objects.create(
+ cid=next_cid,
+ passcode=get_random_string(8),
+ name=f"study:{participant.study.slug}:{participant.pseudo_id}",
+ active=True,
+ )
+ break
+ except Exception:
+ continue
+
+ if created_cid is None:
+ raise RuntimeError("Unable to allocate CID user for participant")
+
+ # Update participant with CidUser (1-1 relationship)
+ try:
+ participant.cid_user = created_cid
+ participant.save(update_fields=["cid_user", "updated_at"])
+ except Exception as e:
+ # If 1-1 constraint violated, another participant may have been created; clean up
+ created_cid.delete()
+ raise RuntimeError(f"Unable to assign CID to participant: {e}")
+
+ return created_cid
+
+
+def _build_study_export_rows(study: ResearchStudy):
+ """Build export rows for study results."""
+ rows = []
+ participants = (
+ study.participants.select_related("assigned_arm", "assigned_arm__packet", "cid_user", "user_user")
+ .order_by("pseudo_id")
+ )
+
+ for participant in participants:
+ raw_score = None
+ max_possible_score = None
+ normalised_percent = None
+ marked_answers = 0
+ total_cases = 0
+ attempt_start = None
+ attempt_end = None
+ attempt_completed = False
+
+ if participant.assigned_arm and participant.cid_user:
+ packet = participant.assigned_arm.packet
+ casedetails = CaseDetail.objects.filter(collection=packet)
+ total_cases = casedetails.count()
+ answers = CidReportAnswer.objects.filter(
+ question__in=casedetails,
+ cid=participant.cid_user.cid,
+ )
+ marked_scores = [a.score for a in answers if a.score is not None]
+ raw_score = sum(marked_scores) if marked_scores else 0
+ marked_answers = len(marked_scores)
+
+ # Get case max score from packet's marking config
+ case_max = 10 # Default
+ if packet.case_question_max_score:
+ case_max = packet.case_question_max_score
+
+ max_possible_score = total_cases * case_max
+ if max_possible_score > 0:
+ normalised_percent = round((raw_score / max_possible_score) * 100, 2)
+
+ attempt = packet.get_cid_exams(cid_user=participant.cid_user).first()
+ if attempt:
+ attempt_start = attempt.start_time
+ attempt_end = attempt.end_time
+ attempt_completed = bool(attempt.completed)
+
+ rows.append(
+ {
+ "study_id": study.pk,
+ "study_slug": study.slug,
+ "study_name": study.name,
+ "pseudo_id": participant.pseudo_id,
+ "candidate_group": participant.candidate_group,
+ "age_band": participant.age_band,
+ "sex": participant.sex,
+ "training_grade": participant.training_grade,
+ "years_experience": participant.years_experience,
+ "email": participant.email,
+ "consented": participant.consented,
+ "assigned_arm": participant.assigned_arm.name if participant.assigned_arm else "",
+ "packet": participant.assigned_arm.packet.name if participant.assigned_arm else "",
+ "assignment_method": participant.assignment_method,
+ "assigned_at": participant.assigned_at,
+ "cid": participant.cid_user.cid if participant.cid_user else "",
+ "passcode": participant.cid_user.passcode if participant.cid_user else "",
+ "attempt_start": attempt_start,
+ "attempt_end": attempt_end,
+ "attempt_completed": attempt_completed,
+ "total_cases": total_cases,
+ "marked_answers": marked_answers,
+ "raw_score": raw_score,
+ "max_possible_score": max_possible_score,
+ "normalised_percent": normalised_percent,
+ }
+ )
+
+ return rows
+
+
+# ============================================================================
+# Study Management Views
+# ============================================================================
+
+@login_required
+def study_list(request):
+ """List research studies accessible to the user."""
+ studies = ResearchStudy.objects.all().prefetch_related("author")
+ if not (request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()):
+ studies = studies.filter(author=request.user)
+ return render(request, "research/study_list.html", {"studies": studies.distinct()})
+
+
+@login_required
+def study_create(request):
+ """Create a new research study."""
+ if request.method == "POST":
+ form = ResearchStudyForm(request.POST)
+ if form.is_valid():
+ study = form.save()
+ study.author.add(request.user)
+ messages.success(request, f"Study '{study.name}' created successfully.")
+ return redirect("research:study_detail", pk=study.pk)
+ else:
+ form = ResearchStudyForm()
+ return render(request, "research/study_form.html", {"form": form, "is_create": True})
+
+
+@login_required
+def study_update(request, pk: int):
+ """Update an existing research study."""
+ study = get_object_or_404(ResearchStudy, pk=pk)
+ if not _user_can_manage_research_study(study, request.user):
+ raise PermissionDenied
+
+ if request.method == "POST":
+ form = ResearchStudyForm(request.POST, instance=study)
+ if form.is_valid():
+ form.save()
+ messages.success(request, f"Study '{study.name}' updated successfully.")
+ return redirect("research:study_detail", pk=study.pk)
+ else:
+ form = ResearchStudyForm(instance=study)
+
+ return render(
+ request,
+ "research/study_form.html",
+ {"form": form, "study": study, "is_create": False},
+ )
+
+
+@login_required
+def study_detail(request, pk: int):
+ """Display study detail with arms and participants."""
+ study = get_object_or_404(ResearchStudy, pk=pk)
+ if not _user_can_manage_research_study(study, request.user):
+ raise PermissionDenied
+
+ arm_form = ResearchStudyArmForm() if request.method != "POST" else None
+ if request.method == "POST" and "add_arm" in request.POST:
+ arm_form = ResearchStudyArmForm(request.POST)
+ if arm_form.is_valid():
+ arm = arm_form.save(commit=False)
+ arm.study = study
+ arm.save()
+ messages.success(request, f"Arm '{arm.name}' added successfully.")
+ return redirect("research:study_detail", pk=study.pk)
+
+ arms = study.arms.select_related("packet").all()
+ participants = study.participants.select_related("assigned_arm", "assigned_arm__packet", "cid_user").all()
+ arm_rows = [
+ (arm, participants.filter(assigned_arm=arm).count())
+ for arm in arms
+ ]
+ return render(
+ request,
+ "research/study_detail.html",
+ {
+ "study": study,
+ "arms": arms,
+ "participants": participants,
+ "arm_rows": arm_rows,
+ "arm_form": arm_form,
+ },
+ )
+
+
+@login_required
+def study_arm_update(request, pk: int, arm_id: int):
+ """Update a study arm."""
+ study = get_object_or_404(ResearchStudy, pk=pk)
+ if not _user_can_manage_research_study(study, request.user):
+ raise PermissionDenied
+
+ arm = get_object_or_404(ResearchStudyArm, pk=arm_id, study=study)
+ if request.method == "POST":
+ form = ResearchStudyArmForm(request.POST, instance=arm, study=study)
+ if form.is_valid():
+ form.save()
+ messages.success(request, f"Arm '{arm.name}' updated successfully.")
+ return redirect("research:study_detail", pk=study.pk)
+ else:
+ form = ResearchStudyArmForm(instance=arm, study=study)
+
+ return render(
+ request,
+ "research/study_arm_form.html",
+ {"study": study, "form": form, "arm": arm},
+ )
+
+
+@login_required
+def study_export_csv(request, pk: int):
+ """Export study results to CSV."""
+ study = get_object_or_404(ResearchStudy, pk=pk)
+ if not _user_can_manage_research_study(study, request.user):
+ raise PermissionDenied
+
+ rows = _build_study_export_rows(study)
+ response = HttpResponse(content_type="text/csv")
+ response["Content-Disposition"] = f'attachment; filename="study_{study.slug}_results.csv"'
+
+ if rows:
+ writer = csv.DictWriter(response, fieldnames=list(rows[0].keys()))
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(row)
+
+ return response
+
+
+@login_required
+def study_export_json(request, pk: int):
+ """Export study results to JSON."""
+ study = get_object_or_404(ResearchStudy, pk=pk)
+ if not _user_can_manage_research_study(study, request.user):
+ raise PermissionDenied
+
+ rows = _build_study_export_rows(study)
+ return JsonResponse(rows, safe=False)
+
+
+@login_required
+def study_bulk_generate(request, pk: int):
+ """Generate participants in bulk from CSV/JSON."""
+ study = get_object_or_404(ResearchStudy, pk=pk)
+ if not _user_can_manage_research_study(study, request.user):
+ raise PermissionDenied
+
+ generated_count = 0
+ error_message = None
+
+ if request.method == "POST":
+ form = BulkParticipantGenerationForm(request.POST, request.FILES)
+ if form.is_valid():
+ try:
+ upload_format = form.cleaned_data["format"]
+ uploaded_file = request.FILES["file"]
+
+ if upload_format == "csv":
+ # Parse CSV
+ decoded_file = uploaded_file.read().decode('utf-8').splitlines()
+ reader = csv.DictReader(decoded_file)
+ for row_num, row in enumerate(reader, start=2): # +2 for header + 1-indexing
+ try:
+ pseudo_id = row.get("pseudo_id", "").strip()
+ candidate_group = row.get("candidate_group", "").strip()
+ email = row.get("email", "").strip()
+
+ if not pseudo_id:
+ error_message = f"Row {row_num}: pseudo_id is required"
+ break
+
+ participant, created = ResearchParticipant.objects.get_or_create(
+ study=study,
+ pseudo_id=pseudo_id,
+ defaults={
+ "candidate_group": candidate_group,
+ "email": email,
+ }
+ )
+ if created:
+ generated_count += 1
+ except Exception as e:
+ error_message = f"Row {row_num}: {str(e)}"
+ break
+
+ elif upload_format == "json":
+ # Parse JSON
+ data = json.load(uploaded_file)
+ if not isinstance(data, list):
+ error_message = "JSON must be an array of participant objects"
+ else:
+ for row_num, row in enumerate(data, start=1):
+ try:
+ pseudo_id = row.get("pseudo_id", "").strip()
+ candidate_group = row.get("candidate_group", "").strip()
+ email = row.get("email", "").strip()
+
+ if not pseudo_id:
+ error_message = f"Record {row_num}: pseudo_id is required"
+ break
+
+ participant, created = ResearchParticipant.objects.get_or_create(
+ study=study,
+ pseudo_id=pseudo_id,
+ defaults={
+ "candidate_group": candidate_group,
+ "email": email,
+ }
+ )
+ if created:
+ generated_count += 1
+ except Exception as e:
+ error_message = f"Record {row_num}: {str(e)}"
+ break
+
+ if error_message:
+ messages.error(request, error_message)
+ else:
+ messages.success(request, f"Generated {generated_count} new participants")
+ return redirect("research:study_detail", pk=study.pk)
+
+ except Exception as e:
+ error_message = f"File processing error: {str(e)}"
+ messages.error(request, error_message)
+ else:
+ form = BulkParticipantGenerationForm()
+
+ return render(
+ request,
+ "research/bulk_generate.html",
+ {"study": study, "form": form},
+ )
+
+
+# ============================================================================
+# Participant Entry & Access
+# ============================================================================
+
+def participant_entry(request, slug: str, pseudo_id: str):
+ """Participant landing page with on-site randomisation and intake form."""
+ study = get_object_or_404(ResearchStudy, slug=slug)
+ if not study.active and not (request.user.is_authenticated and _user_can_manage_research_study(study, request.user)):
+ raise Http404("Study not available")
+
+ # Check generation mode
+ if study.generation_mode == ResearchStudy.GenerationMode.BULK_ONLY:
+ if not request.user.is_authenticated or not _user_can_manage_research_study(study, request.user):
+ # Try to get existing participant
+ try:
+ participant = ResearchParticipant.objects.get(study=study, pseudo_id=pseudo_id)
+ except ResearchParticipant.DoesNotExist:
+ raise Http404("Participant not found. Contact study administrator.")
+ else:
+ # AUTO_ON_ACCESS: create if doesn't exist
+ participant, _ = ResearchParticipant.objects.get_or_create(
+ study=study,
+ pseudo_id=pseudo_id,
+ )
+
+ if request.user.is_authenticated and participant.user_user is None:
+ participant.user_user = request.user
+
+ if request.method == "POST":
+ form = ResearchParticipantIntakeForm(
+ request.POST,
+ instance=participant,
+ collect_demographics=study.collect_demographics,
+ )
+ if form.is_valid():
+ participant = form.save(commit=False)
+ participant.study = study
+
+ with transaction.atomic():
+ if participant.assigned_arm is None:
+ assigned_arm = _choose_research_arm(
+ study,
+ candidate_group=participant.candidate_group,
+ )
+ participant.assigned_arm = assigned_arm
+ participant.assignment_method = study.randomisation_mode
+ participant.assigned_at = timezone.now()
+
+ participant.save()
+ cid_user = _ensure_research_participant_cid(participant)
+
+ # Store email on CidUser if provided
+ if participant.email and not cid_user.email:
+ cid_user.email = participant.email
+ cid_user.save(update_fields=['email'])
+
+ # Ensure packet can be accessed via existing CID flow
+ if participant.assigned_arm is not None:
+ participant.assigned_arm.packet.valid_cid_users.add(cid_user)
+
+ messages.success(request, "Intake complete. You can now start the study.")
+ return redirect(
+ "research:participant_entry",
+ slug=study.slug,
+ pseudo_id=participant.pseudo_id,
+ )
+ else:
+ form = ResearchParticipantIntakeForm(
+ instance=participant,
+ collect_demographics=study.collect_demographics,
+ )
+
+ # Build start URL if assigned
+ assigned_packet = participant.assigned_arm.packet if participant.assigned_arm else None
+ start_url = None
+ if participant.assigned_arm and participant.cid_user:
+ start_url = reverse(
+ "atlas:collection_case_view_take",
+ kwargs={
+ "pk": assigned_packet.pk,
+ "case_number": 0,
+ "cid": participant.cid_user.cid,
+ "passcode": participant.cid_user.passcode,
+ },
+ )
+
+ return render(
+ request,
+ "research/participant_entry.html",
+ {
+ "study": study,
+ "participant": participant,
+ "form": form,
+ "assigned_packet": assigned_packet,
+ "start_url": start_url,
+ },
+ )