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:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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/<study_slug>/<pseudo_id>/</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/<pk>/</code> study detail + packets + participants</li>
|
||||
<li><code>/atlas/research/<pk>/update/</code> update study</li>
|
||||
<li><code>/atlas/research/<pk>/arm/<arm_id>/update/</code> update packet</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Exports</h3>
|
||||
<ul>
|
||||
<li><code>/atlas/research/<pk>/export.csv</code></li>
|
||||
<li><code>/atlas/research/<pk>/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>
|
||||
@@ -52,6 +52,7 @@ INSTALLED_APPS = [
|
||||
"sbas",
|
||||
"wally",
|
||||
"atlas",
|
||||
"research",
|
||||
"rcr",
|
||||
"rota",
|
||||
"oef",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
default_app_config = 'research.apps.ResearchConfig'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'),
|
||||
)
|
||||
@@ -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,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> |
|
||||
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> •
|
||||
{{ study.get_generation_mode_display }} •
|
||||
{{ 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 %}
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user