Compare commits

...
2 Commits
Author SHA1 Message Date
Ross 89e89cef73 feat: Remove slug field from ResearchStudy model and update related references 2026-05-12 21:51:44 +01:00
Ross c94d2fb1a7 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.
2026-05-12 21:37:57 +01:00
31 changed files with 2223 additions and 7 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>
+21
View File
@@ -3,6 +3,27 @@ from django.template import Node, TemplateSyntaxError, Variable
register = template.Library()
@register.simple_tag(takes_context=True)
def update_query_param(context, key, value):
"""Return a querystring with a dynamic key updated.
Django's built-in querystring tag does not allow dynamic keyword names,
so this helper keeps compatibility for django-tables2 prefixed fields.
"""
request = context.get("request")
if request is None:
return ""
params = request.GET.copy()
if value in (None, ""):
params.pop(key, None)
else:
params[key] = value
encoded = params.urlencode()
return f"?{encoded}" if encoded else ""
class HelpNode(Node):
def __init__(self, nodelist, title, icon, extra_class):
self.nodelist = nodelist
+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 = ['id', 'name', 'active', 'generation_mode', 'randomisation_mode', 'created_at']
list_filter = ['active', 'generation_mode', 'randomisation_mode', 'created_at']
search_fields = ['name', 'description']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Study Information', {
'fields': ('name', '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__name', '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'
+230
View File
@@ -0,0 +1,230 @@
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",
"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',
'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, *args, study=None, **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')},
},
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.1.4 on 2026-05-12 20:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('research', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='researchstudy',
name='slug',
),
]
View File
+227
View File
@@ -0,0 +1,227 @@
from django.db import models
from django.conf import settings
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from generic.mixins import AuthorMixin
from generic.models import CidUser
from atlas.models import CaseCollection
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)
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.pk}:{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">Study ID: <code>{{ study.pk }}</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.pk '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>ID</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.pk }}</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/<int:study_id>/<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.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_id}:{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_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(study=study) if request.method != "POST" else None
if request.method == "POST" and "add_arm" in request.POST:
arm_form = ResearchStudyArmForm(request.POST, study=study)
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.pk}_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, study_id: int, pseudo_id: str):
"""Participant landing page with on-site randomisation and intake form."""
study = get_object_or_404(ResearchStudy, pk=study_id)
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:
# Try to get existing participant
try:
participant = ResearchParticipant.objects.get(study=study, pseudo_id=pseudo_id)
except ResearchParticipant.DoesNotExist:
if request.user.is_authenticated and _user_can_manage_research_study(study, request.user):
participant = ResearchParticipant.objects.create(study=study, pseudo_id=pseudo_id)
else:
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",
study_id=study.pk,
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,
},
)
+5 -4
View File
@@ -1,5 +1,6 @@
{% load django_tables2 %}
{% load i18n l10n %}
{% load help_tags %}
{% block table-wrapper %}
<div class="table-container">
{% if table.caption %}
@@ -14,7 +15,7 @@
{% for column in table.columns %}
<th {{ column.attrs.th.as_html }}>
{% if column.orderable %}
<a href="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}">{{ column.header }}</a>
<a href="{% update_query_param table.prefixed_order_by_field column.order_by_alias.next %}">{{ column.header }}</a>
{% else %}
{{ column.header }}
{% endif %}
@@ -64,7 +65,7 @@
{% if table.page.has_previous %}
{% block pagination.previous %}
<li class="previous page-item">
<a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}" class="page-link">
<a href="{% update_query_param table.prefixed_page_field table.page.previous_page_number %}" class="page-link">
<span aria-hidden="true">&laquo;</span>
{% trans 'previous' %}
</a>
@@ -75,7 +76,7 @@
{% block pagination.range %}
{% for p in table.page|table_page_range:table.paginator %}
<li class="page-item{% if table.page.number == p %} active{% endif %}">
<a class="page-link" {% if p != '...' %}href="{% querystring table.prefixed_page_field=p %}"{% endif %}>
<a class="page-link" {% if p != '...' %}href="{% update_query_param table.prefixed_page_field p %}"{% endif %}>
{{ p }}
</a>
</li>
@@ -85,7 +86,7 @@
{% if table.page.has_next %}
{% block pagination.next %}
<li class="next page-item">
<a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}" class="page-link">
<a href="{% update_query_param table.prefixed_page_field table.page.next_page_number %}" class="page-link">
{% trans 'next' %}
<span aria-hidden="true">&raquo;</span>
</a>