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