feat(research): add research study management functionality

- Implemented models for ResearchStudy, ResearchStudyArm, and ResearchParticipant.
- Created views for listing, creating, updating, and exporting research studies.
- Added bulk participant generation feature with CSV and JSON support.
- Developed templates for study management, participant entry, and bulk generation.
- Introduced URL routing for research study operations.
- Added utility functions for randomisation and participant assignment.
- Implemented participant intake process with demographic data collection.
- Ensured proper handling of participant CIDs and assignment methods.
This commit is contained in:
Ross
2026-05-12 21:37:57 +01:00
parent e28bf641b7
commit c94d2fb1a7
28 changed files with 2186 additions and 3 deletions
@@ -0,0 +1,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 %}