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