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.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_id}:{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_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(study=study) if request.method != "POST" else None if request.method == "POST" and "add_arm" in request.POST: arm_form = ResearchStudyArmForm(request.POST, study=study) 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.pk}_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, study_id: int, pseudo_id: str): """Participant landing page with on-site randomisation and intake form.""" study = get_object_or_404(ResearchStudy, pk=study_id) 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: # Try to get existing participant try: participant = ResearchParticipant.objects.get(study=study, pseudo_id=pseudo_id) except ResearchParticipant.DoesNotExist: if request.user.is_authenticated and _user_can_manage_research_study(study, request.user): participant = ResearchParticipant.objects.create(study=study, pseudo_id=pseudo_id) else: 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", study_id=study.pk, 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, }, )