feat(research): add research study management functionality

- Implemented models for ResearchStudy, ResearchStudyArm, and ResearchParticipant.
- Created views for listing, creating, updating, and exporting research studies.
- Added bulk participant generation feature with CSV and JSON support.
- Developed templates for study management, participant entry, and bulk generation.
- Introduced URL routing for research study operations.
- Added utility functions for randomisation and participant assignment.
- Implemented participant intake process with demographic data collection.
- Ensured proper handling of participant CIDs and assignment methods.
This commit is contained in:
Ross
2026-05-12 21:37:57 +01:00
parent e28bf641b7
commit c94d2fb1a7
28 changed files with 2186 additions and 3 deletions
@@ -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),
),
]
+73
View File
@@ -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())
@@ -0,0 +1,32 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>{{ study.name }}</h2>
<p class="text-muted">Participant ID: {{ participant.pseudo_id }}</p>
{% if assigned_packet and start_url %}
<div class="card card-body bg-dark border-secondary mb-3">
<h5>Assignment Complete</h5>
<p class="mb-1">You have been assigned to packet: <strong>{{ participant.assigned_arm.name }}</strong>.</p>
<p class="text-muted small mb-3">Mark scheme: {{ participant.assigned_arm.marking_scheme_name|default:'Default collection scoring' }}</p>
<a class="btn btn-primary" href="{{ start_url }}">Start Packet</a>
</div>
{% else %}
<div class="card card-body bg-dark border-secondary mb-3">
<h5>Consent and Details</h5>
<p class="text-muted">Complete this form to begin. Assignment to packet is performed automatically when you submit.</p>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">Submit and Start</button>
</form>
</div>
{% endif %}
{% if study.description %}
<div class="card card-body bg-dark border-secondary">
{{ study.description|linebreaks }}
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>Update Packet: {{ arm.name }}</h2>
<p class="text-muted">Study: {{ study.name }}</p>
<form method="post" class="card card-body bg-dark border-secondary">
{% csrf_token %}
{{ form|crispy }}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" type="submit">Save</button>
<a class="btn btn-outline-secondary" href="{% url 'atlas:research_study_detail' study.pk %}">Back</a>
</div>
</form>
{% endblock %}
@@ -0,0 +1,96 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>{{ study.name }}</h2>
<p class="text-muted mb-3">Slug: <code>{{ study.slug }}</code></p>
<div class="mb-3 d-flex gap-2 flex-wrap">
<a class="btn btn-outline-primary" href="{% url 'atlas:research_study_update' study.pk %}">Edit Study</a>
<a class="btn btn-outline-secondary" href="{% url 'atlas:research_study_export_csv' study.pk %}">Export CSV</a>
<a class="btn btn-outline-secondary" href="{% url 'atlas:research_study_export_json' study.pk %}">Export JSON</a>
</div>
<div class="card card-body bg-dark border-secondary mb-4">
<h5 class="mb-2">Participant URL</h5>
<p class="small text-muted mb-2">Append the pseudo-anonymised ID to this base URL:</p>
<code>{{ request.scheme }}://{{ request.get_host }}{% url 'atlas:research_participant_entry' study.slug 'PSEUDO_ID' %}</code>
</div>
<div class="row g-4">
<div class="col-12 col-lg-6">
<div class="card card-body bg-dark border-secondary h-100">
<h5>Packets</h5>
{% if arm_rows %}
<table class="table table-dark table-striped table-sm align-middle">
<thead>
<tr>
<th>Name</th>
<th>Collection</th>
<th>Assigned</th>
<th>Mark Scheme</th>
<th></th>
</tr>
</thead>
<tbody>
{% for arm, assigned_count in arm_rows %}
<tr>
<td>{{ arm.name }}</td>
<td>{{ arm.packet.name }}</td>
<td>{{ assigned_count }}</td>
<td>{{ arm.marking_scheme_name|default:'Default' }}</td>
<td><a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:research_study_arm_update' study.pk arm.pk %}">Edit</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-warning">No packets configured. Add at least one packet before sharing participant links.</div>
{% endif %}
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card card-body bg-dark border-secondary h-100">
<h5>Add Packet</h5>
<form method="post">
{% csrf_token %}
{{ arm_form|crispy }}
<button class="btn btn-primary" type="submit">Add Packet</button>
</form>
</div>
</div>
</div>
<div class="card card-body bg-dark border-secondary mt-4">
<h5>Participants ({{ participants|length }})</h5>
{% if participants %}
<div class="table-responsive">
<table class="table table-dark table-striped table-sm">
<thead>
<tr>
<th>Pseudo ID</th>
<th>Group</th>
<th>Assigned Packet</th>
<th>CID</th>
<th>Assigned At</th>
</tr>
</thead>
<tbody>
{% for participant in participants %}
<tr>
<td>{{ participant.pseudo_id }}</td>
<td>{{ participant.candidate_group|default:'-' }}</td>
<td>{% if participant.assigned_arm %}{{ participant.assigned_arm.name }}{% else %}-{% endif %}</td>
<td>{% if participant.cid_user %}{{ participant.cid_user.cid }}{% else %}-{% endif %}</td>
<td>{{ participant.assigned_at|default:'-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info mb-0">No participants yet.</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>{% if is_create %}Create Research Study{% else %}Update Research Study{% endif %}</h2>
<p class="text-muted">Configure randomisation and participant intake options. Packets are added on the study detail page.</p>
<form method="post" class="card card-body bg-dark border-secondary">
{% csrf_token %}
{{ form|crispy }}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" type="submit">Save</button>
<a class="btn btn-outline-secondary" href="{% if study %}{% url 'atlas:research_study_detail' study.pk %}{% else %}{% url 'atlas:research_study_list' %}{% endif %}">Cancel</a>
</div>
</form>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "atlas/base.html" %}
{% block content %}
<h2>Research Studies</h2>
<p class="text-muted">Create and manage study packets that randomise participants to Atlas collections.</p>
<div class="mb-3">
<a class="btn btn-primary" href="{% url 'atlas:research_study_create' %}">Create Study</a>
</div>
{% if studies %}
<table class="table table-dark table-striped table-hover table-sm">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Status</th>
<th>Randomisation</th>
<th></th>
</tr>
</thead>
<tbody>
{% for study in studies %}
<tr>
<td>{{ study.name }}</td>
<td>{{ study.slug }}</td>
<td>
{% if study.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td>{{ study.get_randomisation_mode_display }}</td>
<td><a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:research_study_detail' study.pk %}">Open</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-info">No studies have been created yet.</div>
{% endif %}
{% endblock %}
+5
View File
@@ -99,6 +99,11 @@ urlpatterns = [
views.collection_sync_prerequisite_users,
name="collection_sync_prerequisite_users",
),
path(
"collection/<int:pk>/apply_question_template",
views.collection_apply_question_template,
name="collection_apply_question_template",
),
path("collection/<int:pk>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
path(
"collection/<int:pk>/toggle_results_published",
+33 -3
View File
@@ -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(
'<div class="alert alert-warning">No question template set on this collection.</div>'
)
overwrite = request.GET.get("overwrite", "false").lower() == "true"
updated_count = collection.apply_question_template_to_cases(overwrite_existing=overwrite)
return HttpResponse(
f'<div class="alert alert-success">Applied question template to {updated_count} case(s).</div>'
)
@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.
+172
View File
@@ -0,0 +1,172 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Research Platform Extension - Implementation Summary</title>
<style>
:root {
--bg: #0f1720;
--panel: #162130;
--text: #e6edf3;
--muted: #9fb0c3;
--accent: #57a6ff;
--ok: #3fb950;
--warn: #d29922;
--border: #2d3a4f;
}
body {
margin: 0;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background: radial-gradient(circle at 20% 10%, #1b2a40, var(--bg));
color: var(--text);
line-height: 1.5;
}
.wrap {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
margin-bottom: 16px;
}
h1, h2, h3 { margin-top: 0; }
h1 { font-size: 1.6rem; }
h2 { font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
code { color: var(--accent); }
.muted { color: var(--muted); }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); margin-right: 6px; }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
ul { margin-top: 8px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid var(--border); padding: 8px; text-align: left; }
th { background: #1a2738; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Research Platform Extension</h1>
<p class="muted">Generated: 12 May 2026</p>
<p>
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.
</p>
<div>
<span class="tag">Randomisation on site</span>
<span class="tag">Pseudo-ID workflow</span>
<span class="tag">Per-packet marking config</span>
<span class="tag">CSV/JSON export</span>
</div>
</div>
<div class="card">
<h2>Implemented Data Model</h2>
<ul>
<li><code>ResearchStudy</code>: study container with slug, active flag, demographic collection toggle, and randomisation mode.</li>
<li><code>ResearchStudyArm</code>: packet definition linked to existing <code>CaseCollection</code>, including weighting and marking scheme metadata.</li>
<li><code>ResearchParticipant</code>: pseudo-ID keyed participant with demographic fields, consent flag, assigned arm, and generated CID credential link.</li>
</ul>
<p>Migration generated: <code>atlas/migrations/0103_researchstudy_researchstudyarm_researchparticipant.py</code>.</p>
</div>
<div class="card">
<h2>Participant Flow</h2>
<ul>
<li>Participant visits: <code>/atlas/research/run/&lt;study_slug&gt;/&lt;pseudo_id&gt;/</code>.</li>
<li>Site captures consent and simple demographics (configurable per study).</li>
<li>Participant is randomised to a packet (study arm) by configured strategy.</li>
<li>A dedicated <code>CidUser</code> credential is created and attached to the participant if needed.</li>
<li>Assigned packet <code>CaseCollection</code> is launched via existing candidate collection routes.</li>
</ul>
<p class="ok">This reuses current Atlas taking/timing/marking behavior and avoids changes to candidate-facing scoring internals.</p>
</div>
<div class="card">
<h2>Randomisation Logic</h2>
<table>
<thead>
<tr><th>Mode</th><th>Behavior</th></tr>
</thead>
<tbody>
<tr><td>Completely random</td><td>Weighted random choice over active arms using <code>allocation_weight</code>.</td></tr>
<tr><td>Balanced (equal-fill)</td><td>Assigns to least-filled active arm; tie broken randomly.</td></tr>
<tr><td>Balanced within group</td><td>If enabled, balancing is applied within <code>candidate_group</code> strata.</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h2>Marking and Scoring</h2>
<ul>
<li>Existing answer models remain in use: <code>CidReportAnswer</code> / <code>UserReportAnswer</code>.</li>
<li>Existing collection mark views remain functional for non-technical markers.</li>
<li>Per-packet configuration fields added on study arm:
<ul>
<li><code>marking_scheme_name</code></li>
<li><code>marking_guidance</code></li>
<li><code>scoring_mode</code> and <code>max_case_score</code> for normalised exports</li>
</ul>
</li>
</ul>
<p class="warn">Current marker UI itself is unchanged; packet-specific configuration is reflected in study management and exports.</p>
</div>
<div class="card">
<h2>Management and Export</h2>
<div class="grid">
<div>
<h3>Management Views</h3>
<ul>
<li><code>/atlas/research/</code> list studies</li>
<li><code>/atlas/research/create/</code> create study</li>
<li><code>/atlas/research/&lt;pk&gt;/</code> study detail + packets + participants</li>
<li><code>/atlas/research/&lt;pk&gt;/update/</code> update study</li>
<li><code>/atlas/research/&lt;pk&gt;/arm/&lt;arm_id&gt;/update/</code> update packet</li>
</ul>
</div>
<div>
<h3>Exports</h3>
<ul>
<li><code>/atlas/research/&lt;pk&gt;/export.csv</code></li>
<li><code>/atlas/research/&lt;pk&gt;/export.json</code></li>
</ul>
<p>Export rows include demographics, assignment metadata, credential ID, attempt status, and raw/normalised scores.</p>
</div>
</div>
</div>
<div class="card">
<h2>Files Added/Updated</h2>
<ul>
<li>Updated: <code>atlas/models.py</code>, <code>atlas/forms.py</code>, <code>atlas/views.py</code>, <code>atlas/urls.py</code>, <code>atlas/admin.py</code></li>
<li>Added templates:
<ul>
<li><code>atlas/templates/atlas/research_study_list.html</code></li>
<li><code>atlas/templates/atlas/research_study_form.html</code></li>
<li><code>atlas/templates/atlas/research_study_detail.html</code></li>
<li><code>atlas/templates/atlas/research_study_arm_form.html</code></li>
<li><code>atlas/templates/atlas/research_participant_entry.html</code></li>
</ul>
</li>
<li>Added migration: <code>atlas/migrations/0103_researchstudy_researchstudyarm_researchparticipant.py</code></li>
</ul>
</div>
<div class="card">
<h2>Compatibility Notes</h2>
<ul>
<li>No existing collection, case, or answer model behavior was removed.</li>
<li>Randomisation and participant routing are additive and isolated to new research URLs.</li>
<li>Author/editor permission pattern is respected for study management views.</li>
</ul>
</div>
</div>
</body>
</html>
+1
View File
@@ -52,6 +52,7 @@ INSTALLED_APPS = [
"sbas",
"wally",
"atlas",
"research",
"rcr",
"rota",
"oef",
+1
View File
@@ -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"),
+1
View File
@@ -0,0 +1 @@
default_app_config = 'research.apps.ResearchConfig'
+116
View File
@@ -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(
'<code>{}</code>',
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(
'<strong>CID:</strong> {}<br><strong>Passcode:</strong> <code>{}</code><br><strong>Name:</strong> {}',
obj.cid_user.cid,
obj.cid_user.passcode,
obj.cid_user.name
)
return 'Not yet assigned'
cid_user_info.short_description = 'CID User Information'
+7
View File
@@ -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'
+232
View File
@@ -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('<small class="form-text text-muted">For target-based allocation, provide JSON like: {"arm_id": count, ...}</small>'),
),
),
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('<small class="form-text text-muted">Set order_sequence to enforce ordering. Set required_previous_arm to enforce prerequisites.</small>'),
),
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('<div class="alert alert-info">Provide an email address to reaccess your results later if you forget your passcode.</div>'),
'consented',
HTML('<div class="alert alert-warning"><strong>Consent Required:</strong> You must consent to participate to continue.</div>'),
),
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('<small class="form-text text-muted">CSV format: pseudo_id,candidate_group,email<br>JSON format: [{"pseudo_id": "P001", "candidate_group": "A", "email": "..."}]</small>'),
),
Submit('submit', 'Generate Participants'),
)
+95
View File
@@ -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')},
},
),
]
View File
+231
View File
@@ -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()
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2>Bulk Generate Participants</h2>
<p class="text-muted mb-0">Study: <a href="{% url 'research:study_detail' study.pk %}">{{ study.name }}</a></p>
</div>
</div>
<div class="row g-4">
<div class="col-12 col-lg-6">
<div class="card card-body bg-dark border-secondary">
<h5>Upload Participants</h5>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary mt-2" type="submit">Generate Participants</button>
<a class="btn btn-outline-secondary mt-2 ms-2" href="{% url 'research:study_detail' study.pk %}">Cancel</a>
</form>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card card-body bg-dark border-secondary">
<h5>Format Reference</h5>
<p class="text-muted small">CSV format (with header row):</p>
<pre class="bg-dark border rounded p-2 small">pseudo_id,candidate_group,email
P001,Group A,opt@example.com
P002,Group B,</pre>
<hr class="border-secondary">
<p class="text-muted small">JSON format:</p>
<pre class="bg-dark border rounded p-2 small">[
{"pseudo_id": "P001", "candidate_group": "A"},
{"pseudo_id": "P002", "candidate_group": "B", "email": "..."}
]</pre>
<p class="text-muted small mt-2">
<strong>Notes:</strong>
<ul class="small text-muted">
<li>Existing participants with the same pseudo_id will not be overwritten.</li>
<li>CID credentials are generated when participants complete intake.</li>
<li>email field is optional.</li>
</ul>
</p>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<h2>{{ study.name }}</h2>
<p class="text-muted">Participant ID: <code>{{ participant.pseudo_id }}</code></p>
{% if study.description %}
<div class="card card-body bg-dark border-secondary mb-4">
{{ study.description|linebreaks }}
</div>
{% endif %}
{% if assigned_packet and start_url and participant.consented %}
<!-- Participant has been assigned and completed intake -->
<div class="card card-body bg-dark border-secondary mb-3">
<h5>Ready to Start</h5>
<p class="mb-1">You have been assigned to: <strong>{{ participant.assigned_arm.name }}</strong></p>
<p class="mb-1 text-muted">Collection: {{ assigned_packet.name }}</p>
<!-- Passcode reminder block -->
<div class="alert alert-info mt-3">
<strong>Your Access Credentials</strong><br>
Keep note of these to reaccess your results later:<br>
CID: <code>{{ participant.cid_user.cid }}</code> &nbsp;|&nbsp;
Passcode: <code>{{ participant.cid_user.passcode }}</code>
{% if participant.email %}
<br><small class="text-muted">Results can also be accessed via email: {{ participant.email }}</small>
{% endif %}
</div>
<a class="btn btn-primary" href="{{ start_url }}">Start</a>
</div>
{% elif participant.assigned_arm %}
<!-- Assigned but no start_url — CID not yet assigned -->
<div class="card card-body bg-dark border-secondary mb-3">
<h5>Assignment Complete</h5>
<p class="text-muted">Refreshing to load your credentials...</p>
</div>
{% else %}
<!-- Not yet assigned - show intake form -->
<div class="card card-body bg-dark border-secondary mb-3">
<h5>Participant Intake</h5>
<p class="text-muted">
Complete this form to be assigned to a study packet.
{% if study.collect_demographics %}
We collect anonymous demographic data to support analysis.
{% endif %}
</p>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary mt-2" type="submit">Submit and Get Assignment</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<h2>{% if arm %}Edit Arm: {{ arm.name }}{% else %}Add Arm{% endif %}</h2>
<p class="text-muted">Study: <a href="{% url 'research:study_detail' study.pk %}">{{ study.name }}</a></p>
<div class="card card-body bg-dark border-secondary">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" type="submit">Save Arm</button>
<a class="btn btn-outline-secondary" href="{% url 'research:study_detail' study.pk %}">Cancel</a>
</div>
</form>
</div>
{% if arm and arm.packet %}
<div class="card card-body bg-dark border-secondary mt-3">
<h6>Collection Marking Configuration</h6>
<p class="small text-muted mb-2">
Marking guidance and scheme names are now configured on the <a href="{% url 'atlas:collection_detail' arm.packet.pk %}">CaseCollection</a> directly.
This allows the same configuration to be reused across multiple studies.
</p>
<ul class="list-group list-group-flush bg-transparent">
<li class="list-group-item bg-transparent text-light">
<strong>Marking Scheme:</strong> {{ arm.packet.marking_scheme_name|default:'(not set)' }}
</li>
<li class="list-group-item bg-transparent text-light">
<strong>Max Score Per Case:</strong> {{ arm.packet.case_question_max_score|default:'10 (default)' }}
</li>
<li class="list-group-item bg-transparent text-light">
<strong>Marking Guidance:</strong><br>
<span class="text-muted">{{ arm.packet.marking_guidance|default:'(not set)' }}</span>
</li>
</ul>
<a class="btn btn-sm btn-outline-info mt-2" href="{% url 'atlas:collection_detail' arm.packet.pk %}">
Edit Collection Marking Config
</a>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,169 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2>{{ study.name }}</h2>
<p class="text-muted mb-0">Slug: <code>{{ study.slug }}</code> &bull;
{{ study.get_generation_mode_display }} &bull;
{{ study.get_randomisation_mode_display }}
</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-primary" href="{% url 'research:study_update' study.pk %}">Edit Study</a>
<a class="btn btn-outline-secondary" href="{% url 'research:study_export_csv' study.pk %}">Export CSV</a>
<a class="btn btn-outline-secondary" href="{% url 'research:study_export_json' study.pk %}">Export JSON</a>
<a class="btn btn-outline-info" href="{% url 'research:study_bulk_generate' study.pk %}">Bulk Generate</a>
</div>
</div>
<!-- Participant Entry URL -->
<div class="card card-body bg-dark border-secondary mb-4">
<h5 class="mb-2">Participant Entry URL Template</h5>
<p class="small text-muted mb-2">Append the pseudo-anonymised ID to this base URL to share with participants:</p>
<code>{{ request.scheme }}://{{ request.get_host }}{% url 'research:participant_entry' study.slug 'PSEUDO_ID' %}</code>
<div class="mt-2">
<small class="text-muted">
Participants use CID + passcode generated at intake to access their results.
{% if study.collect_demographics %}
Demographics collected at intake.
{% endif %}
</small>
</div>
</div>
<div class="row g-4">
<!-- Arms -->
<div class="col-12 col-lg-7">
<div class="card card-body bg-dark border-secondary h-100">
<h5>Arms / Packets</h5>
{% if arm_rows %}
<table class="table table-dark table-striped table-sm align-middle">
<thead>
<tr>
<th>Name</th>
<th>Collection</th>
<th>Assigned</th>
<th>Prerequisite</th>
<th>Mark Scheme</th>
<th></th>
</tr>
</thead>
<tbody>
{% for arm, assigned_count in arm_rows %}
<tr>
<td>{{ arm.name }}</td>
<td>
<a href="{% url 'atlas:collection_detail' arm.packet.pk %}">{{ arm.packet.name }}</a>
{% if arm.packet.marking_scheme_name %}
<br><small class="text-muted">{{ arm.packet.marking_scheme_name }}</small>
{% endif %}
</td>
<td>{{ assigned_count }}</td>
<td>
{% if arm.required_previous_arm %}
<span class="badge bg-warning text-dark">{{ arm.required_previous_arm.name }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ arm.packet.marking_scheme_name|default:'-' }}</td>
<td>
<a class="btn btn-sm btn-outline-primary" href="{% url 'research:study_arm_update' study.pk arm.pk %}">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-warning">No packets configured. Add at least one packet before sharing participant links.</div>
{% endif %}
</div>
</div>
<!-- Add Arm -->
<div class="col-12 col-lg-5">
<div class="card card-body bg-dark border-secondary h-100">
<h5>Add Arm</h5>
<form method="post">
{% csrf_token %}
<input type="hidden" name="add_arm" value="1">
{{ arm_form|crispy }}
<button class="btn btn-primary" type="submit">Add Arm</button>
</form>
</div>
</div>
</div>
<!-- Participants -->
<div class="card card-body bg-dark border-secondary mt-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5>Participants ({{ participants|length }})</h5>
</div>
{% if participants %}
<div class="table-responsive">
<table class="table table-dark table-striped table-sm">
<thead>
<tr>
<th>Pseudo ID</th>
<th>Group</th>
<th>Assigned Arm</th>
<th>CID</th>
<th>Email</th>
<th>Consented</th>
<th>Assigned At</th>
</tr>
</thead>
<tbody>
{% for participant in participants %}
<tr>
<td><code>{{ participant.pseudo_id }}</code></td>
<td>{{ participant.candidate_group|default:'-' }}</td>
<td>
{% if participant.assigned_arm %}
{{ participant.assigned_arm.name }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if participant.cid_user %}
<code>{{ participant.cid_user.cid }}</code>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if participant.email %}
{{ participant.email }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if participant.consented %}
<span class="badge bg-success">Yes</span>
{% else %}
<span class="badge bg-danger">No</span>
{% endif %}
</td>
<td>{{ participant.assigned_at|default:'-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info mb-0">No participants yet.
{% if study.generation_mode == 'BULK' %}
Use <a href="{% url 'research:study_bulk_generate' study.pk %}">Bulk Generate</a> to add participants.
{% else %}
Participants will be created automatically when they access the participant URL.
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<h2>{% if is_create %}Create Research Study{% else %}Edit: {{ study.name }}{% endif %}</h2>
<p class="text-muted">Configure randomisation and participant intake options. Packets are added on the study detail page.</p>
<form method="post" class="card card-body bg-dark border-secondary">
{% csrf_token %}
{{ form|crispy }}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" type="submit">Save</button>
{% if study %}
<a class="btn btn-outline-secondary" href="{% url 'research:study_detail' study.pk %}">Cancel</a>
{% else %}
<a class="btn btn-outline-secondary" href="{% url 'research:study_list' %}">Cancel</a>
{% endif %}
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Research Studies</h2>
<a class="btn btn-primary" href="{% url 'research:study_create' %}">+ Create Study</a>
</div>
<p class="text-muted">Create and manage research study packets that randomise participants to Atlas case collections.</p>
{% if studies %}
<table class="table table-dark table-striped table-hover table-sm">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Status</th>
<th>Randomisation</th>
<th>Participants</th>
<th></th>
</tr>
</thead>
<tbody>
{% for study in studies %}
<tr>
<td>{{ study.name }}</td>
<td><code>{{ study.slug }}</code></td>
<td>
{% if study.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td>{{ study.get_randomisation_mode_display }}</td>
<td>{{ study.participants.count }}</td>
<td>
<a class="btn btn-sm btn-outline-primary" href="{% url 'research:study_detail' study.pk %}">Open</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-info">No studies have been created yet.</div>
{% endif %}
</div>
{% endblock %}
View File
+23
View File
@@ -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("<int:pk>/", views.study_detail, name="study_detail"),
path("<int:pk>/update/", views.study_update, name="study_update"),
path("<int:pk>/arm/<int:arm_id>/", views.study_arm_update, name="study_arm_update"),
# Exports
path("<int:pk>/export.csv", views.study_export_csv, name="study_export_csv"),
path("<int:pk>/export.json", views.study_export_json, name="study_export_json"),
# Bulk generation
path("<int:pk>/bulk-generate/", views.study_bulk_generate, name="study_bulk_generate"),
# Public participant entry (no login required)
path("run/<slug:slug>/<str:pseudo_id>/", views.participant_entry, name="participant_entry"),
]
+559
View File
@@ -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,
},
)