feat(research): add research study management functionality

- Implemented models for ResearchStudy, ResearchStudyArm, and ResearchParticipant.
- Created views for listing, creating, updating, and exporting research studies.
- Added bulk participant generation feature with CSV and JSON support.
- Developed templates for study management, participant entry, and bulk generation.
- Introduced URL routing for research study operations.
- Added utility functions for randomisation and participant assignment.
- Implemented participant intake process with demographic data collection.
- Ensured proper handling of participant CIDs and assignment methods.
This commit is contained in:
Ross
2026-05-12 21:37:57 +01:00
parent e28bf641b7
commit c94d2fb1a7
28 changed files with 2186 additions and 3 deletions
@@ -0,0 +1,38 @@
# Generated by Django 6.0.1 on 2026-05-12 20:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0102_alter_seriesimage_image_blake3_hash_and_more'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='auto_apply_question_template',
field=models.BooleanField(default=False, help_text='If enabled, the case_question_template is automatically applied to new cases added to this collection that do not have an existing question_schema.'),
),
migrations.AddField(
model_name='casecollection',
name='case_question_max_score',
field=models.PositiveIntegerField(blank=True, help_text='Maximum score per case used for normalisation. Defaults to 10 when not set.', null=True),
),
migrations.AddField(
model_name='casecollection',
name='case_question_template',
field=models.JSONField(blank=True, help_text='JSON schema applied as a default question template for all cases in this collection. Individual cases can override via CaseDetail.question_schema.', null=True),
),
migrations.AddField(
model_name='casecollection',
name='marking_guidance',
field=models.TextField(blank=True, help_text='Plain-language marking guidance displayed to markers.'),
),
migrations.AddField(
model_name='casecollection',
name='marking_scheme_name',
field=models.CharField(blank=True, help_text='Human-readable mark scheme name shown to markers and in exports.', max_length=255),
),
]
+73
View File
@@ -1458,6 +1458,47 @@ class CaseCollection(ExamOrCollectionGenericBase):
default=VIEWER_MODE_CHOICES.BUILT_IN,
)
# -----------------------------------------------------------------------
# Marking configuration
# These fields can be defined here so that the same CaseCollection can be
# reused in multiple research studies or standalone with consistent marking.
# -----------------------------------------------------------------------
marking_scheme_name = models.CharField(
max_length=255,
blank=True,
help_text="Human-readable mark scheme name shown to markers and in exports.",
)
marking_guidance = models.TextField(
blank=True,
help_text="Plain-language marking guidance displayed to markers.",
)
case_question_max_score = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Maximum score per case used for normalisation. Defaults to 10 when not set.",
)
# -----------------------------------------------------------------------
# Case question template
# When set, this JSON schema is used as a default for all CaseDetail entries
# in the collection that do not have their own question_schema.
# -----------------------------------------------------------------------
case_question_template = models.JSONField(
null=True,
blank=True,
help_text=(
"JSON schema applied as a default question template for all cases in this collection. "
"Individual cases can override via CaseDetail.question_schema."
),
)
auto_apply_question_template = models.BooleanField(
default=False,
help_text=(
"If enabled, the case_question_template is automatically applied to new cases "
"added to this collection that do not have an existing question_schema."
),
)
def get_app_name(self):
return "atlas"
@@ -1546,6 +1587,38 @@ class CaseCollection(ExamOrCollectionGenericBase):
else:
return cases[new_index]
def apply_question_template_to_cases(self, overwrite_existing=False):
"""
Apply case_question_template to all CaseDetail entries in this collection.
Args:
overwrite_existing: If True, overwrite existing question_schema on CaseDetails.
If False (default), only apply to cases without a schema.
Returns:
int: Number of CaseDetail entries updated.
"""
if not self.case_question_template:
return 0
casedetails = self.casedetail_set.all()
updated = 0
for cd in casedetails:
if overwrite_existing or not cd.question_schema:
cd.question_schema = self.case_question_template
cd.save(update_fields=["question_schema"])
updated += 1
return updated
def get_effective_question_schema(self, casedetail):
"""
Return the effective question schema for a CaseDetail.
Uses the individual CaseDetail schema if set, otherwise falls back to the collection template.
"""
if casedetail.question_schema:
return casedetail.question_schema
return self.case_question_template
def get_index_of_case(self, case, case_count=False):
cases = list(self.get_cases())
@@ -0,0 +1,32 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>{{ study.name }}</h2>
<p class="text-muted">Participant ID: {{ participant.pseudo_id }}</p>
{% if assigned_packet and start_url %}
<div class="card card-body bg-dark border-secondary mb-3">
<h5>Assignment Complete</h5>
<p class="mb-1">You have been assigned to packet: <strong>{{ participant.assigned_arm.name }}</strong>.</p>
<p class="text-muted small mb-3">Mark scheme: {{ participant.assigned_arm.marking_scheme_name|default:'Default collection scoring' }}</p>
<a class="btn btn-primary" href="{{ start_url }}">Start Packet</a>
</div>
{% else %}
<div class="card card-body bg-dark border-secondary mb-3">
<h5>Consent and Details</h5>
<p class="text-muted">Complete this form to begin. Assignment to packet is performed automatically when you submit.</p>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">Submit and Start</button>
</form>
</div>
{% endif %}
{% if study.description %}
<div class="card card-body bg-dark border-secondary">
{{ study.description|linebreaks }}
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>Update Packet: {{ arm.name }}</h2>
<p class="text-muted">Study: {{ study.name }}</p>
<form method="post" class="card card-body bg-dark border-secondary">
{% csrf_token %}
{{ form|crispy }}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" type="submit">Save</button>
<a class="btn btn-outline-secondary" href="{% url 'atlas:research_study_detail' study.pk %}">Back</a>
</div>
</form>
{% endblock %}
@@ -0,0 +1,96 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>{{ study.name }}</h2>
<p class="text-muted mb-3">Slug: <code>{{ study.slug }}</code></p>
<div class="mb-3 d-flex gap-2 flex-wrap">
<a class="btn btn-outline-primary" href="{% url 'atlas:research_study_update' study.pk %}">Edit Study</a>
<a class="btn btn-outline-secondary" href="{% url 'atlas:research_study_export_csv' study.pk %}">Export CSV</a>
<a class="btn btn-outline-secondary" href="{% url 'atlas:research_study_export_json' study.pk %}">Export JSON</a>
</div>
<div class="card card-body bg-dark border-secondary mb-4">
<h5 class="mb-2">Participant URL</h5>
<p class="small text-muted mb-2">Append the pseudo-anonymised ID to this base URL:</p>
<code>{{ request.scheme }}://{{ request.get_host }}{% url 'atlas:research_participant_entry' study.slug 'PSEUDO_ID' %}</code>
</div>
<div class="row g-4">
<div class="col-12 col-lg-6">
<div class="card card-body bg-dark border-secondary h-100">
<h5>Packets</h5>
{% if arm_rows %}
<table class="table table-dark table-striped table-sm align-middle">
<thead>
<tr>
<th>Name</th>
<th>Collection</th>
<th>Assigned</th>
<th>Mark Scheme</th>
<th></th>
</tr>
</thead>
<tbody>
{% for arm, assigned_count in arm_rows %}
<tr>
<td>{{ arm.name }}</td>
<td>{{ arm.packet.name }}</td>
<td>{{ assigned_count }}</td>
<td>{{ arm.marking_scheme_name|default:'Default' }}</td>
<td><a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:research_study_arm_update' study.pk arm.pk %}">Edit</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-warning">No packets configured. Add at least one packet before sharing participant links.</div>
{% endif %}
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card card-body bg-dark border-secondary h-100">
<h5>Add Packet</h5>
<form method="post">
{% csrf_token %}
{{ arm_form|crispy }}
<button class="btn btn-primary" type="submit">Add Packet</button>
</form>
</div>
</div>
</div>
<div class="card card-body bg-dark border-secondary mt-4">
<h5>Participants ({{ participants|length }})</h5>
{% if participants %}
<div class="table-responsive">
<table class="table table-dark table-striped table-sm">
<thead>
<tr>
<th>Pseudo ID</th>
<th>Group</th>
<th>Assigned Packet</th>
<th>CID</th>
<th>Assigned At</th>
</tr>
</thead>
<tbody>
{% for participant in participants %}
<tr>
<td>{{ participant.pseudo_id }}</td>
<td>{{ participant.candidate_group|default:'-' }}</td>
<td>{% if participant.assigned_arm %}{{ participant.assigned_arm.name }}{% else %}-{% endif %}</td>
<td>{% if participant.cid_user %}{{ participant.cid_user.cid }}{% else %}-{% endif %}</td>
<td>{{ participant.assigned_at|default:'-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info mb-0">No participants yet.</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>{% if is_create %}Create Research Study{% else %}Update Research Study{% endif %}</h2>
<p class="text-muted">Configure randomisation and participant intake options. Packets are added on the study detail page.</p>
<form method="post" class="card card-body bg-dark border-secondary">
{% csrf_token %}
{{ form|crispy }}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" type="submit">Save</button>
<a class="btn btn-outline-secondary" href="{% if study %}{% url 'atlas:research_study_detail' study.pk %}{% else %}{% url 'atlas:research_study_list' %}{% endif %}">Cancel</a>
</div>
</form>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "atlas/base.html" %}
{% block content %}
<h2>Research Studies</h2>
<p class="text-muted">Create and manage study packets that randomise participants to Atlas collections.</p>
<div class="mb-3">
<a class="btn btn-primary" href="{% url 'atlas:research_study_create' %}">Create Study</a>
</div>
{% if studies %}
<table class="table table-dark table-striped table-hover table-sm">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Status</th>
<th>Randomisation</th>
<th></th>
</tr>
</thead>
<tbody>
{% for study in studies %}
<tr>
<td>{{ study.name }}</td>
<td>{{ study.slug }}</td>
<td>
{% if study.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td>{{ study.get_randomisation_mode_display }}</td>
<td><a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:research_study_detail' study.pk %}">Open</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-info">No studies have been created yet.</div>
{% endif %}
{% endblock %}
+5
View File
@@ -99,6 +99,11 @@ urlpatterns = [
views.collection_sync_prerequisite_users,
name="collection_sync_prerequisite_users",
),
path(
"collection/<int:pk>/apply_question_template",
views.collection_apply_question_template,
name="collection_apply_question_template",
),
path("collection/<int:pk>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
path(
"collection/<int:pk>/toggle_results_published",
+33 -3
View File
@@ -1,5 +1,7 @@
import json
import pprint
import csv
import random
from typing import Any
from dal import autocomplete
from django.db.models.base import Model as Model
@@ -10,6 +12,7 @@ from django.db import transaction
from django.shortcuts import render, get_object_or_404, redirect
from django import forms
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.html import escape
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_http_methods
@@ -55,7 +58,7 @@ import os
from django.core.files.base import ContentFile
from django.core.exceptions import PermissionDenied
from generic.models import CidUser, CidUserExam, CimarCase, Site
from generic.models import CidUser, CidUserExam, CimarCase, Site, get_next_cid
from atlas.helpers import get_cases_available_to_user
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
@@ -128,7 +131,7 @@ from .models import (
UncategorisedDicom,
UserReportAnswer,
PrerequisiteRequired
, APIToken
, APIToken,
)
from .models import Procedure
from .models import NormalCase
@@ -7547,7 +7550,9 @@ def collection_scores_cid(request, pk):
# ignore scores of 0 for stats
user_scores_list = [i for i in user_scores.values() if i > 0]
max_score = len(cases)
# Use collection's case_question_max_score if set, otherwise default to 10
case_max = collection.case_question_max_score or 10
max_score = len(cases) * case_max
if len(user_scores_list) < 1:
mean = 0
@@ -8015,6 +8020,31 @@ def collection_question_schemas(request, exam_id: int):
)
@user_is_collection_author_or_atlas_editor
def collection_apply_question_template(request, pk: int):
"""Apply the collection's case_question_template to all CaseDetail entries.
Scope: Collection author / atlas_editor. HTMX endpoint.
Accepts optional 'overwrite' query param to overwrite existing schemas.
"""
if not request.htmx:
raise Http404("Invalid request")
collection = get_object_or_404(CaseCollection, pk=pk)
if not collection.case_question_template:
return HttpResponse(
'<div class="alert alert-warning">No question template set on this collection.</div>'
)
overwrite = request.GET.get("overwrite", "false").lower() == "true"
updated_count = collection.apply_question_template_to_cases(overwrite_existing=overwrite)
return HttpResponse(
f'<div class="alert alert-success">Applied question template to {updated_count} case(s).</div>'
)
@user_is_collection_author_or_atlas_editor
def collection_sync_prerequisite_users(request, pk: int):
"""Copy valid cid_users and user_users from all prerequisite collections into this collection.