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,
|
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):
|
def get_app_name(self):
|
||||||
return "atlas"
|
return "atlas"
|
||||||
|
|
||||||
@@ -1546,6 +1587,38 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
|||||||
else:
|
else:
|
||||||
return cases[new_index]
|
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):
|
def get_index_of_case(self, case, case_count=False):
|
||||||
cases = list(self.get_cases())
|
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,
|
views.collection_sync_prerequisite_users,
|
||||||
name="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>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
|
||||||
path(
|
path(
|
||||||
"collection/<int:pk>/toggle_results_published",
|
"collection/<int:pk>/toggle_results_published",
|
||||||
|
|||||||
+33
-3
@@ -1,5 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import pprint
|
import pprint
|
||||||
|
import csv
|
||||||
|
import random
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from dal import autocomplete
|
from dal import autocomplete
|
||||||
from django.db.models.base import Model as Model
|
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.shortcuts import render, get_object_or_404, redirect
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
from django.views.decorators.http import require_http_methods
|
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.files.base import ContentFile
|
||||||
from django.core.exceptions import PermissionDenied
|
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 atlas.helpers import get_cases_available_to_user
|
||||||
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
||||||
|
|
||||||
@@ -128,7 +131,7 @@ from .models import (
|
|||||||
UncategorisedDicom,
|
UncategorisedDicom,
|
||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
PrerequisiteRequired
|
PrerequisiteRequired
|
||||||
, APIToken
|
, APIToken,
|
||||||
)
|
)
|
||||||
from .models import Procedure
|
from .models import Procedure
|
||||||
from .models import NormalCase
|
from .models import NormalCase
|
||||||
@@ -7547,7 +7550,9 @@ def collection_scores_cid(request, pk):
|
|||||||
# ignore scores of 0 for stats
|
# ignore scores of 0 for stats
|
||||||
user_scores_list = [i for i in user_scores.values() if i > 0]
|
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:
|
if len(user_scores_list) < 1:
|
||||||
mean = 0
|
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
|
@user_is_collection_author_or_atlas_editor
|
||||||
def collection_sync_prerequisite_users(request, pk: int):
|
def collection_sync_prerequisite_users(request, pk: int):
|
||||||
"""Copy valid cid_users and user_users from all prerequisite collections into this collection.
|
"""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",
|
"sbas",
|
||||||
"wally",
|
"wally",
|
||||||
"atlas",
|
"atlas",
|
||||||
|
"research",
|
||||||
"rcr",
|
"rcr",
|
||||||
"rota",
|
"rota",
|
||||||
"oef",
|
"oef",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ urlpatterns = [
|
|||||||
path("sbas/", include("sbas.urls"), name="sbas"),
|
path("sbas/", include("sbas.urls"), name="sbas"),
|
||||||
path("generic/", include("generic.urls"), name="generic"),
|
path("generic/", include("generic.urls"), name="generic"),
|
||||||
path("atlas/", include("atlas.urls"), name="atlas"),
|
path("atlas/", include("atlas.urls"), name="atlas"),
|
||||||
|
path("research/", include("research.urls"), name="research"),
|
||||||
path("rcr/", include("rcr.urls"), name="rcr"),
|
path("rcr/", include("rcr.urls"), name="rcr"),
|
||||||
path("rota/", include("rota.urls"), name="rota"),
|
path("rota/", include("rota.urls"), name="rota"),
|
||||||
path("oef/", include("oef.urls"), name="oef"),
|
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