Files
penracourses/research/views.py
T
Ross c94d2fb1a7 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.
2026-05-12 21:37:57 +01:00

560 lines
22 KiB
Python

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,
},
)