From c94d2fb1a78484bde03165af6fb14c856a1662bf Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 12 May 2026 21:37:57 +0100 Subject: [PATCH] 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. --- ...n_auto_apply_question_template_and_more.py | 38 ++ atlas/models.py | 73 +++ .../atlas/research_participant_entry.html | 32 + .../atlas/research_study_arm_form.html | 16 + .../atlas/research_study_detail.html | 96 +++ .../templates/atlas/research_study_form.html | 16 + .../templates/atlas/research_study_list.html | 43 ++ atlas/urls.py | 5 + atlas/views.py | 36 +- docs/research_platform_implementation.html | 172 ++++++ rad/settings.py | 1 + rad/urls.py | 1 + research/__init__.py | 1 + research/admin.py | 116 ++++ research/apps.py | 7 + research/forms.py | 232 ++++++++ research/migrations/0001_initial.py | 95 +++ research/migrations/__init__.py | 0 research/models.py | 231 ++++++++ .../templates/research/bulk_generate.html | 51 ++ .../templates/research/participant_entry.html | 61 ++ .../templates/research/study_arm_form.html | 45 ++ research/templates/research/study_detail.html | 169 ++++++ research/templates/research/study_form.html | 22 + research/templates/research/study_list.html | 48 ++ research/tests/__init__.py | 0 research/urls.py | 23 + research/views.py | 559 ++++++++++++++++++ 28 files changed, 2186 insertions(+), 3 deletions(-) create mode 100644 atlas/migrations/0103_casecollection_auto_apply_question_template_and_more.py create mode 100644 atlas/templates/atlas/research_participant_entry.html create mode 100644 atlas/templates/atlas/research_study_arm_form.html create mode 100644 atlas/templates/atlas/research_study_detail.html create mode 100644 atlas/templates/atlas/research_study_form.html create mode 100644 atlas/templates/atlas/research_study_list.html create mode 100644 docs/research_platform_implementation.html create mode 100644 research/__init__.py create mode 100644 research/admin.py create mode 100644 research/apps.py create mode 100644 research/forms.py create mode 100644 research/migrations/0001_initial.py create mode 100644 research/migrations/__init__.py create mode 100644 research/models.py create mode 100644 research/templates/research/bulk_generate.html create mode 100644 research/templates/research/participant_entry.html create mode 100644 research/templates/research/study_arm_form.html create mode 100644 research/templates/research/study_detail.html create mode 100644 research/templates/research/study_form.html create mode 100644 research/templates/research/study_list.html create mode 100644 research/tests/__init__.py create mode 100644 research/urls.py create mode 100644 research/views.py diff --git a/atlas/migrations/0103_casecollection_auto_apply_question_template_and_more.py b/atlas/migrations/0103_casecollection_auto_apply_question_template_and_more.py new file mode 100644 index 00000000..8e776cf5 --- /dev/null +++ b/atlas/migrations/0103_casecollection_auto_apply_question_template_and_more.py @@ -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), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index a0974144..0f4afc3e 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -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()) diff --git a/atlas/templates/atlas/research_participant_entry.html b/atlas/templates/atlas/research_participant_entry.html new file mode 100644 index 00000000..a4dd10ad --- /dev/null +++ b/atlas/templates/atlas/research_participant_entry.html @@ -0,0 +1,32 @@ +{% extends "atlas/base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +

{{ study.name }}

+

Participant ID: {{ participant.pseudo_id }}

+ +{% if assigned_packet and start_url %} +
+
Assignment Complete
+

You have been assigned to packet: {{ participant.assigned_arm.name }}.

+

Mark scheme: {{ participant.assigned_arm.marking_scheme_name|default:'Default collection scoring' }}

+ Start Packet +
+{% else %} +
+
Consent and Details
+

Complete this form to begin. Assignment to packet is performed automatically when you submit.

+
+ {% csrf_token %} + {{ form|crispy }} + +
+
+{% endif %} + +{% if study.description %} +
+ {{ study.description|linebreaks }} +
+{% endif %} +{% endblock %} diff --git a/atlas/templates/atlas/research_study_arm_form.html b/atlas/templates/atlas/research_study_arm_form.html new file mode 100644 index 00000000..f3421a7c --- /dev/null +++ b/atlas/templates/atlas/research_study_arm_form.html @@ -0,0 +1,16 @@ +{% extends "atlas/base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +

Update Packet: {{ arm.name }}

+

Study: {{ study.name }}

+ +
+ {% csrf_token %} + {{ form|crispy }} +
+ + Back +
+
+{% endblock %} diff --git a/atlas/templates/atlas/research_study_detail.html b/atlas/templates/atlas/research_study_detail.html new file mode 100644 index 00000000..8fa6ce16 --- /dev/null +++ b/atlas/templates/atlas/research_study_detail.html @@ -0,0 +1,96 @@ +{% extends "atlas/base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +

{{ study.name }}

+

Slug: {{ study.slug }}

+ +
+ Edit Study + Export CSV + Export JSON +
+ +
+
Participant URL
+

Append the pseudo-anonymised ID to this base URL:

+ {{ request.scheme }}://{{ request.get_host }}{% url 'atlas:research_participant_entry' study.slug 'PSEUDO_ID' %} +
+ +
+
+
+
Packets
+ {% if arm_rows %} + + + + + + + + + + + + {% for arm, assigned_count in arm_rows %} + + + + + + + + {% endfor %} + +
NameCollectionAssignedMark Scheme
{{ arm.name }}{{ arm.packet.name }}{{ assigned_count }}{{ arm.marking_scheme_name|default:'Default' }}Edit
+ {% else %} +
No packets configured. Add at least one packet before sharing participant links.
+ {% endif %} +
+
+ +
+
+
Add Packet
+
+ {% csrf_token %} + {{ arm_form|crispy }} + +
+
+
+
+ +
+
Participants ({{ participants|length }})
+ {% if participants %} +
+ + + + + + + + + + + + {% for participant in participants %} + + + + + + + + {% endfor %} + +
Pseudo IDGroupAssigned PacketCIDAssigned At
{{ participant.pseudo_id }}{{ participant.candidate_group|default:'-' }}{% if participant.assigned_arm %}{{ participant.assigned_arm.name }}{% else %}-{% endif %}{% if participant.cid_user %}{{ participant.cid_user.cid }}{% else %}-{% endif %}{{ participant.assigned_at|default:'-' }}
+
+ {% else %} +
No participants yet.
+ {% endif %} +
+{% endblock %} diff --git a/atlas/templates/atlas/research_study_form.html b/atlas/templates/atlas/research_study_form.html new file mode 100644 index 00000000..745f7048 --- /dev/null +++ b/atlas/templates/atlas/research_study_form.html @@ -0,0 +1,16 @@ +{% extends "atlas/base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +

{% if is_create %}Create Research Study{% else %}Update Research Study{% endif %}

+

Configure randomisation and participant intake options. Packets are added on the study detail page.

+ +
+ {% csrf_token %} + {{ form|crispy }} +
+ + Cancel +
+
+{% endblock %} diff --git a/atlas/templates/atlas/research_study_list.html b/atlas/templates/atlas/research_study_list.html new file mode 100644 index 00000000..de46148f --- /dev/null +++ b/atlas/templates/atlas/research_study_list.html @@ -0,0 +1,43 @@ +{% extends "atlas/base.html" %} + +{% block content %} +

Research Studies

+

Create and manage study packets that randomise participants to Atlas collections.

+ +
+ Create Study +
+ +{% if studies %} + + + + + + + + + + + + {% for study in studies %} + + + + + + + + {% endfor %} + +
NameSlugStatusRandomisation
{{ study.name }}{{ study.slug }} + {% if study.active %} + Active + {% else %} + Inactive + {% endif %} + {{ study.get_randomisation_mode_display }}Open
+{% else %} +
No studies have been created yet.
+{% endif %} +{% endblock %} diff --git a/atlas/urls.py b/atlas/urls.py index 4c6415aa..2bc13a22 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -99,6 +99,11 @@ urlpatterns = [ views.collection_sync_prerequisite_users, name="collection_sync_prerequisite_users", ), + path( + "collection//apply_question_template", + views.collection_apply_question_template, + name="collection_apply_question_template", + ), path("collection//authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"), path( "collection//toggle_results_published", diff --git a/atlas/views.py b/atlas/views.py index 9edbc75c..ea13ae48 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -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( + '
No question template set on this collection.
' + ) + + overwrite = request.GET.get("overwrite", "false").lower() == "true" + updated_count = collection.apply_question_template_to_cases(overwrite_existing=overwrite) + + return HttpResponse( + f'
Applied question template to {updated_count} case(s).
' + ) + + @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. diff --git a/docs/research_platform_implementation.html b/docs/research_platform_implementation.html new file mode 100644 index 00000000..6a698945 --- /dev/null +++ b/docs/research_platform_implementation.html @@ -0,0 +1,172 @@ + + + + + + Research Platform Extension - Implementation Summary + + + +
+
+

Research Platform Extension

+

Generated: 12 May 2026

+

+ The Atlas app was extended to support multi-study research workflows that randomise participants + to packeted case collections while reusing existing collection-taking, timing, marking, and attempt models. +

+
+ Randomisation on site + Pseudo-ID workflow + Per-packet marking config + CSV/JSON export +
+
+ +
+

Implemented Data Model

+
    +
  • ResearchStudy: study container with slug, active flag, demographic collection toggle, and randomisation mode.
  • +
  • ResearchStudyArm: packet definition linked to existing CaseCollection, including weighting and marking scheme metadata.
  • +
  • ResearchParticipant: pseudo-ID keyed participant with demographic fields, consent flag, assigned arm, and generated CID credential link.
  • +
+

Migration generated: atlas/migrations/0103_researchstudy_researchstudyarm_researchparticipant.py.

+
+ +
+

Participant Flow

+
    +
  • Participant visits: /atlas/research/run/<study_slug>/<pseudo_id>/.
  • +
  • Site captures consent and simple demographics (configurable per study).
  • +
  • Participant is randomised to a packet (study arm) by configured strategy.
  • +
  • A dedicated CidUser credential is created and attached to the participant if needed.
  • +
  • Assigned packet CaseCollection is launched via existing candidate collection routes.
  • +
+

This reuses current Atlas taking/timing/marking behavior and avoids changes to candidate-facing scoring internals.

+
+ +
+

Randomisation Logic

+ + + + + + + + + +
ModeBehavior
Completely randomWeighted random choice over active arms using allocation_weight.
Balanced (equal-fill)Assigns to least-filled active arm; tie broken randomly.
Balanced within groupIf enabled, balancing is applied within candidate_group strata.
+
+ +
+

Marking and Scoring

+
    +
  • Existing answer models remain in use: CidReportAnswer / UserReportAnswer.
  • +
  • Existing collection mark views remain functional for non-technical markers.
  • +
  • Per-packet configuration fields added on study arm: +
      +
    • marking_scheme_name
    • +
    • marking_guidance
    • +
    • scoring_mode and max_case_score for normalised exports
    • +
    +
  • +
+

Current marker UI itself is unchanged; packet-specific configuration is reflected in study management and exports.

+
+ +
+

Management and Export

+
+
+

Management Views

+
    +
  • /atlas/research/ list studies
  • +
  • /atlas/research/create/ create study
  • +
  • /atlas/research/<pk>/ study detail + packets + participants
  • +
  • /atlas/research/<pk>/update/ update study
  • +
  • /atlas/research/<pk>/arm/<arm_id>/update/ update packet
  • +
+
+
+

Exports

+
    +
  • /atlas/research/<pk>/export.csv
  • +
  • /atlas/research/<pk>/export.json
  • +
+

Export rows include demographics, assignment metadata, credential ID, attempt status, and raw/normalised scores.

+
+
+
+ +
+

Files Added/Updated

+
    +
  • Updated: atlas/models.py, atlas/forms.py, atlas/views.py, atlas/urls.py, atlas/admin.py
  • +
  • Added templates: +
      +
    • atlas/templates/atlas/research_study_list.html
    • +
    • atlas/templates/atlas/research_study_form.html
    • +
    • atlas/templates/atlas/research_study_detail.html
    • +
    • atlas/templates/atlas/research_study_arm_form.html
    • +
    • atlas/templates/atlas/research_participant_entry.html
    • +
    +
  • +
  • Added migration: atlas/migrations/0103_researchstudy_researchstudyarm_researchparticipant.py
  • +
+
+ +
+

Compatibility Notes

+
    +
  • No existing collection, case, or answer model behavior was removed.
  • +
  • Randomisation and participant routing are additive and isolated to new research URLs.
  • +
  • Author/editor permission pattern is respected for study management views.
  • +
+
+
+ + diff --git a/rad/settings.py b/rad/settings.py index 0a6e1941..12772b8b 100644 --- a/rad/settings.py +++ b/rad/settings.py @@ -52,6 +52,7 @@ INSTALLED_APPS = [ "sbas", "wally", "atlas", + "research", "rcr", "rota", "oef", diff --git a/rad/urls.py b/rad/urls.py index 42499b7b..f1a6805a 100644 --- a/rad/urls.py +++ b/rad/urls.py @@ -50,6 +50,7 @@ urlpatterns = [ path("sbas/", include("sbas.urls"), name="sbas"), path("generic/", include("generic.urls"), name="generic"), path("atlas/", include("atlas.urls"), name="atlas"), + path("research/", include("research.urls"), name="research"), path("rcr/", include("rcr.urls"), name="rcr"), path("rota/", include("rota.urls"), name="rota"), path("oef/", include("oef.urls"), name="oef"), diff --git a/research/__init__.py b/research/__init__.py new file mode 100644 index 00000000..0c5ad5b9 --- /dev/null +++ b/research/__init__.py @@ -0,0 +1 @@ +default_app_config = 'research.apps.ResearchConfig' diff --git a/research/admin.py b/research/admin.py new file mode 100644 index 00000000..535d9de9 --- /dev/null +++ b/research/admin.py @@ -0,0 +1,116 @@ +from django.contrib import admin +from django.utils.html import format_html +from research.models import ResearchStudy, ResearchStudyArm, ResearchParticipant + + +@admin.register(ResearchStudy) +class ResearchStudyAdmin(admin.ModelAdmin): + list_display = ['name', 'slug', 'active', 'generation_mode', 'randomisation_mode', 'created_at'] + list_filter = ['active', 'generation_mode', 'randomisation_mode', 'created_at'] + search_fields = ['name', 'slug', 'description'] + readonly_fields = ['created_at', 'updated_at'] + fieldsets = ( + ('Study Information', { + 'fields': ('name', 'slug', 'description', 'active') + }), + ('Participant Creation', { + 'fields': ('generation_mode', 'collect_demographics'), + 'description': 'Configure how participants are created and what data is collected.' + }), + ('Randomisation Strategy', { + 'fields': ('randomisation_mode', 'balance_within_candidate_group', 'allocation_targets'), + 'description': 'Configure how participants are assigned to arms.' + }), + ('Authors', { + 'fields': ('author',), + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +@admin.register(ResearchStudyArm) +class ResearchStudyArmAdmin(admin.ModelAdmin): + list_display = ['name', 'study', 'packet', 'active', 'sort_order', 'order_sequence'] + list_filter = ['study', 'active', 'order_sequence'] + search_fields = ['name', 'study__name', 'packet__name'] + fieldsets = ( + ('Arm Information', { + 'fields': ('study', 'name', 'packet', 'active', 'sort_order') + }), + ('Randomisation', { + 'fields': ('allocation_weight',), + 'description': 'Used for completely-random allocation.' + }), + ('Ordering & Prerequisites', { + 'fields': ('order_sequence', 'required_previous_arm'), + 'description': 'Configure sequential completion requirements.' + }), + ) + + def get_form(self, request, obj=None, **kwargs): + """Pre-filter related fields based on study.""" + form = super().get_form(request, obj, **kwargs) + if obj and hasattr(obj, 'study'): + form.base_fields['required_previous_arm'].queryset = ResearchStudyArm.objects.filter( + study=obj.study + ).exclude(pk=obj.pk) + return form + + +@admin.register(ResearchParticipant) +class ResearchParticipantAdmin(admin.ModelAdmin): + list_display = ['pseudo_id', 'study', 'assigned_arm', 'cid_user_display', 'consented', 'assigned_at'] + list_filter = ['study', 'assigned_arm', 'consented', 'created_at'] + search_fields = ['pseudo_id', 'study__slug', 'cid_user__cid', 'email'] + readonly_fields = ['created_at', 'updated_at', 'cid_user_info'] + fieldsets = ( + ('Study & ID', { + 'fields': ('study', 'pseudo_id') + }), + ('CID User', { + 'fields': ('cid_user', 'cid_user_info'), + 'description': 'Associated CID user for study access.' + }), + ('Demographics', { + 'fields': ('candidate_group', 'age_band', 'sex', 'training_grade', 'years_experience', 'email'), + }), + ('Assignment', { + 'fields': ('assigned_arm', 'assignment_method', 'assigned_at'), + }), + ('Consent & Status', { + 'fields': ('consented', 'completed_arms'), + }), + ('User Link', { + 'fields': ('user_user',), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def cid_user_display(self, obj): + """Display CID number with link.""" + if obj.cid_user: + return format_html( + '{}', + obj.cid_user.cid + ) + return '-' + cid_user_display.short_description = 'CID' + + def cid_user_info(self, obj): + """Display full CID user info.""" + if obj.cid_user: + return format_html( + 'CID: {}
Passcode: {}
Name: {}', + obj.cid_user.cid, + obj.cid_user.passcode, + obj.cid_user.name + ) + return 'Not yet assigned' + cid_user_info.short_description = 'CID User Information' diff --git a/research/apps.py b/research/apps.py new file mode 100644 index 00000000..1aa4c822 --- /dev/null +++ b/research/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ResearchConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'research' + verbose_name = 'Research Studies' diff --git a/research/forms.py b/research/forms.py new file mode 100644 index 00000000..cd352af8 --- /dev/null +++ b/research/forms.py @@ -0,0 +1,232 @@ +from django import forms +from django.forms import ( + ModelForm, + ValidationError, + CheckboxInput, + TextInput, +) +from crispy_forms.helper import FormHelper +from crispy_forms.layout import ( + Layout, + Fieldset, + Div, + Row, + Column, + Submit, + HTML, +) + +from research.models import ResearchStudy, ResearchStudyArm, ResearchParticipant + + +class ResearchStudyForm(ModelForm): + """Form for creating/editing research studies.""" + + class Meta: + model = ResearchStudy + fields = [ + "name", + "slug", + "description", + "active", + "randomisation_mode", + "generation_mode", + "balance_within_candidate_group", + "collect_demographics", + "allocation_targets", + ] + widgets = { + "description": forms.Textarea(attrs={"rows": 4}), + "allocation_targets": forms.Textarea(attrs={"rows": 4, "placeholder": '{"arm_1": 30, "arm_2": 30}'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.layout = Layout( + Fieldset( + 'Study Details', + 'name', + 'slug', + 'description', + 'active', + ), + Fieldset( + 'Participant Creation', + 'generation_mode', + 'collect_demographics', + ), + Fieldset( + 'Randomisation Strategy', + 'randomisation_mode', + 'balance_within_candidate_group', + Row( + Column('allocation_targets', css_class='col-md-12'), + HTML('For target-based allocation, provide JSON like: {"arm_id": count, ...}'), + ), + ), + Submit('submit', 'Save Study'), + ) + + +class ResearchStudyArmForm(ModelForm): + """Form for creating/editing study arms (packets).""" + + class Meta: + model = ResearchStudyArm + fields = [ + "name", + "packet", + "active", + "allocation_weight", + "sort_order", + "order_sequence", + "required_previous_arm", + ] + + def __init__(self, study=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + + # Filter previous_arm choices to arms in the same study + if study: + self.fields['required_previous_arm'].queryset = ResearchStudyArm.objects.filter( + study=study + ).exclude(pk=self.instance.pk if self.instance.pk else None) + else: + self.fields['required_previous_arm'].queryset = ResearchStudyArm.objects.none() + + self.helper.layout = Layout( + Fieldset( + 'Arm Configuration', + 'name', + 'packet', + 'active', + 'sort_order', + ), + Fieldset( + 'Randomisation', + 'allocation_weight', + ), + Fieldset( + 'Ordering & Prerequisites', + 'order_sequence', + 'required_previous_arm', + HTML('Set order_sequence to enforce ordering. Set required_previous_arm to enforce prerequisites.'), + ), + Submit('submit', 'Save Arm'), + ) + + +class ResearchParticipantIntakeForm(ModelForm): + """Form for participant intake (demographics and consent).""" + + class Meta: + model = ResearchParticipant + fields = [ + "candidate_group", + "age_band", + "sex", + "training_grade", + "years_experience", + "email", + "consented", + ] + widgets = { + "candidate_group": TextInput(attrs={"placeholder": "e.g. Group A"}), + "age_band": TextInput(attrs={"placeholder": "e.g. 30-39"}), + "sex": TextInput(attrs={"placeholder": "Optional"}), + "training_grade": TextInput(attrs={"placeholder": "e.g. ST4"}), + "years_experience": TextInput(attrs={"placeholder": "e.g. 5"}), + "email": TextInput(attrs={"placeholder": "optional@example.com", "type": "email"}), + "consented": CheckboxInput(), + } + + def __init__(self, *args, collect_demographics=True, **kwargs): + super().__init__(*args, **kwargs) + self.collect_demographics = collect_demographics + + if not collect_demographics: + # Make demographic fields optional if not collecting + for f in [ + "candidate_group", + "age_band", + "sex", + "training_grade", + "years_experience", + ]: + self.fields[f].required = False + else: + # Make candidate_group required if collecting (useful for balancing) + self.fields["candidate_group"].required = True + + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.layout = Layout( + Fieldset( + 'Demographics', + 'candidate_group', + 'age_band', + 'sex', + 'training_grade', + 'years_experience', + ), + Fieldset( + 'Contact & Consent', + 'email', + HTML('
Provide an email address to reaccess your results later if you forget your passcode.
'), + 'consented', + HTML('
Consent Required: You must consent to participate to continue.
'), + ), + Submit('submit', 'Start Study'), + ) + + def clean_consented(self): + """Require explicit consent.""" + consented = self.cleaned_data.get("consented") + if not consented: + raise ValidationError("Consent is required to continue.") + return consented + + def clean_email(self): + """Validate email format if provided.""" + email = self.cleaned_data.get("email", "").strip() + if email: + # Basic email validation is done by EmailField, but we can add custom logic here + pass + return email + + +class BulkParticipantGenerationForm(forms.Form): + """Form for bulk generating participants from CSV/JSON.""" + + UPLOAD_FORMAT_CHOICES = [ + ('csv', 'CSV (pseudo_id, candidate_group, email)'), + ('json', 'JSON (array of objects)'), + ] + + format = forms.ChoiceField( + choices=UPLOAD_FORMAT_CHOICES, + initial='csv', + help_text="Choose upload format", + ) + + file = forms.FileField( + help_text="CSV or JSON file with participant data", + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.layout = Layout( + Fieldset( + 'Bulk Generate Participants', + 'format', + 'file', + HTML('CSV format: pseudo_id,candidate_group,email
JSON format: [{"pseudo_id": "P001", "candidate_group": "A", "email": "..."}]
'), + ), + Submit('submit', 'Generate Participants'), + ) diff --git a/research/migrations/0001_initial.py b/research/migrations/0001_initial.py new file mode 100644 index 00000000..10acd3ad --- /dev/null +++ b/research/migrations/0001_initial.py @@ -0,0 +1,95 @@ +# Generated by Django 6.0.1 on 2026-05-12 20:35 + +import django.db.models.deletion +import generic.mixins +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('atlas', '0103_casecollection_auto_apply_question_template_and_more'), + ('generic', '0032_ciduserexam_shared_with_users'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ResearchStudy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.TextField(blank=True)), + ('active', models.BooleanField(default=True)), + ('randomisation_mode', models.CharField(choices=[('RANDOM', 'Completely random'), ('BALANCED', 'Balanced (equal-fill)'), ('BALANCED_GROUP', 'Balanced within candidate group'), ('TARGET_BASED', 'Target-based allocation')], default='BALANCED', help_text='How participants are assigned to packets.', max_length=20)), + ('generation_mode', models.CharField(choices=[('AUTO', 'Auto-create on first access'), ('BULK', 'Bulk generation only')], default='AUTO', help_text='How participants are created for this study.', max_length=20)), + ('balance_within_candidate_group', models.BooleanField(default=True, help_text='If enabled, balanced randomisation is performed independently per candidate group.')), + ('collect_demographics', models.BooleanField(default=True, help_text='If enabled, participants are asked for simple demographic fields before starting.')), + ('allocation_targets', models.JSONField(blank=True, default=dict, help_text="For target-based allocation: maps arm_id or group_name to target count. E.g. {'arm_1': 30, 'arm_2': 30}")), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('author', models.ManyToManyField(blank=True, help_text='Users allowed to manage this study.', related_name='research_studies', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Research Study', + 'verbose_name_plural': 'Research Studies', + 'ordering': ('-created_at',), + }, + bases=(models.Model, generic.mixins.AuthorMixin), + ), + migrations.CreateModel( + name='ResearchStudyArm', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('active', models.BooleanField(default=True)), + ('allocation_weight', models.PositiveIntegerField(default=1, help_text='Relative weighting used for completely-random assignment.')), + ('sort_order', models.PositiveIntegerField(default=100)), + ('order_sequence', models.PositiveIntegerField(default=0, help_text='Order in which this arm should be completed (0 = no ordering). Used with prerequisite support.')), + ('packet', models.ForeignKey(help_text='CaseCollection used as this packet.', on_delete=django.db.models.deletion.PROTECT, related_name='research_arms', to='atlas.casecollection')), + ('required_previous_arm', models.ForeignKey(blank=True, help_text='If set, participant must complete this arm before the dependent arm.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_arms', to='research.researchstudyarm')), + ('study', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='arms', to='research.researchstudy')), + ], + options={ + 'verbose_name': 'Research Study Arm', + 'verbose_name_plural': 'Research Study Arms', + 'ordering': ('sort_order', 'id'), + 'unique_together': {('study', 'name')}, + }, + ), + migrations.CreateModel( + name='ResearchParticipant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pseudo_id', models.CharField(help_text='Pseudo-anonymised ID used in participant URL.', max_length=100)), + ('candidate_group', models.CharField(blank=True, max_length=100)), + ('age_band', models.CharField(blank=True, max_length=100)), + ('sex', models.CharField(blank=True, max_length=50)), + ('training_grade', models.CharField(blank=True, max_length=100)), + ('years_experience', models.CharField(blank=True, max_length=50)), + ('demographics_extra', models.JSONField(blank=True, default=dict)), + ('email', models.EmailField(blank=True, help_text='Email address for participant result reaccess.', max_length=254)), + ('consented', models.BooleanField(default=False)), + ('assignment_method', models.CharField(blank=True, choices=[('RANDOM', 'Completely random'), ('BALANCED', 'Balanced'), ('BALANCED_GROUP', 'Balanced within group'), ('TARGET_BASED', 'Target-based'), ('MANUAL', 'Manual')], max_length=20)), + ('assigned_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('cid_user', models.OneToOneField(blank=True, help_text='CidUser for this participant (1-1 mapping; assigned at intake).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='research_participant', to='generic.ciduser')), + ('user_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='research_participants', to=settings.AUTH_USER_MODEL)), + ('study', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='research.researchstudy')), + ('assigned_arm', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='participants', to='research.researchstudyarm')), + ('completed_arms', models.ManyToManyField(blank=True, help_text='Arms this participant has completed.', related_name='completed_by_participants', to='research.researchstudyarm')), + ], + options={ + 'verbose_name': 'Research Participant', + 'verbose_name_plural': 'Research Participants', + 'ordering': ('-created_at',), + 'constraints': [models.UniqueConstraint(fields=('study', 'cid_user'), name='unique_cid_per_study')], + 'unique_together': {('study', 'pseudo_id')}, + }, + ), + ] diff --git a/research/migrations/__init__.py b/research/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/research/models.py b/research/models.py new file mode 100644 index 00000000..70691f72 --- /dev/null +++ b/research/models.py @@ -0,0 +1,231 @@ +from django.db import models +from django.conf import settings +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError +from generic.mixins import AuthorMixin +from generic.models import CidUser +from atlas.models import CaseCollection +from loguru import logger + + +class ResearchStudy(models.Model, AuthorMixin): + """Container for a research trial that assigns participants to collection packets.""" + + class RandomisationMode(models.TextChoices): + COMPLETELY_RANDOM = "RANDOM", _("Completely random") + BALANCED = "BALANCED", _("Balanced (equal-fill)") + BALANCED_WITHIN_GROUP = "BALANCED_GROUP", _("Balanced within candidate group") + TARGET_BASED = "TARGET_BASED", _("Target-based allocation") + + class GenerationMode(models.TextChoices): + AUTO_ON_ACCESS = "AUTO", _("Auto-create on first access") + BULK_ONLY = "BULK", _("Bulk generation only") + + name = models.CharField(max_length=255) + slug = models.SlugField(max_length=100, unique=True) + description = models.TextField(blank=True) + + active = models.BooleanField(default=True) + + randomisation_mode = models.CharField( + max_length=20, + choices=RandomisationMode.choices, + default=RandomisationMode.BALANCED, + help_text="How participants are assigned to packets.", + ) + + generation_mode = models.CharField( + max_length=20, + choices=GenerationMode.choices, + default=GenerationMode.AUTO_ON_ACCESS, + help_text="How participants are created for this study.", + ) + + balance_within_candidate_group = models.BooleanField( + default=True, + help_text="If enabled, balanced randomisation is performed independently per candidate group.", + ) + + collect_demographics = models.BooleanField( + default=True, + help_text="If enabled, participants are asked for simple demographic fields before starting.", + ) + + allocation_targets = models.JSONField( + default=dict, + blank=True, + help_text="For target-based allocation: maps arm_id or group_name to target count. E.g. {'arm_1': 30, 'arm_2': 30}", + ) + + author = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + help_text="Users allowed to manage this study.", + related_name="research_studies", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("-created_at",) + verbose_name = "Research Study" + verbose_name_plural = "Research Studies" + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("research:study_detail", kwargs={"pk": self.pk}) + + +class ResearchStudyArm(models.Model): + """One packet/arm in a study linked to a CaseCollection.""" + + study = models.ForeignKey( + ResearchStudy, + on_delete=models.CASCADE, + related_name="arms", + ) + name = models.CharField(max_length=100) + packet = models.ForeignKey( + CaseCollection, + on_delete=models.PROTECT, + related_name="research_arms", + help_text="CaseCollection used as this packet.", + ) + active = models.BooleanField(default=True) + allocation_weight = models.PositiveIntegerField( + default=1, + help_text="Relative weighting used for completely-random assignment.", + ) + sort_order = models.PositiveIntegerField(default=100) + + # Support for ordered packets with prerequisites + order_sequence = models.PositiveIntegerField( + default=0, + help_text="Order in which this arm should be completed (0 = no ordering). Used with prerequisite support.", + ) + required_previous_arm = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="dependent_arms", + help_text="If set, participant must complete this arm before the dependent arm.", + ) + + class Meta: + ordering = ("sort_order", "id") + unique_together = ("study", "name") + verbose_name = "Research Study Arm" + verbose_name_plural = "Research Study Arms" + + def __str__(self): + return f"{self.study.name}: {self.name}" + + +class ResearchParticipant(models.Model): + """Pseudo-anonymised participant record for one study with 1-1 CidUser mapping.""" + + class AssignmentMethod(models.TextChoices): + COMPLETELY_RANDOM = "RANDOM", _("Completely random") + BALANCED = "BALANCED", _("Balanced") + BALANCED_WITHIN_GROUP = "BALANCED_GROUP", _("Balanced within group") + TARGET_BASED = "TARGET_BASED", _("Target-based") + MANUAL = "MANUAL", _("Manual") + + study = models.ForeignKey( + ResearchStudy, + on_delete=models.CASCADE, + related_name="participants", + ) + pseudo_id = models.CharField( + max_length=100, + help_text="Pseudo-anonymised ID used in participant URL.", + ) + + # 1-1 mapping to CidUser per study. Nullable until participant completes intake. + cid_user = models.OneToOneField( + CidUser, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="research_participant", + help_text="CidUser for this participant (1-1 mapping; assigned at intake).", + ) + + # Optional link to authenticated user (if participant logged in) + user_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="research_participants", + ) + + # Demographics captured at intake + candidate_group = models.CharField(max_length=100, blank=True) + age_band = models.CharField(max_length=100, blank=True) + sex = models.CharField(max_length=50, blank=True) + training_grade = models.CharField(max_length=100, blank=True) + years_experience = models.CharField(max_length=50, blank=True) + demographics_extra = models.JSONField(default=dict, blank=True) + + # Email for result reaccess (stored on CidUser, but captured here for convenience) + email = models.EmailField( + blank=True, + help_text="Email address for participant result reaccess.", + ) + + consented = models.BooleanField(default=False) + + # Arm assignment + assigned_arm = models.ForeignKey( + ResearchStudyArm, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="participants", + ) + assignment_method = models.CharField( + max_length=20, + choices=AssignmentMethod.choices, + blank=True, + ) + assigned_at = models.DateTimeField(null=True, blank=True) + + # Completion tracking + completed_arms = models.ManyToManyField( + ResearchStudyArm, + blank=True, + related_name="completed_by_participants", + help_text="Arms this participant has completed.", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ("study", "pseudo_id") + # Ensure CidUser is unique per study (1-1 mapping) + constraints = [ + models.UniqueConstraint( + fields=['study', 'cid_user'], + name='unique_cid_per_study' + ) + ] + ordering = ("-created_at",) + verbose_name = "Research Participant" + verbose_name_plural = "Research Participants" + + def __str__(self): + return f"{self.study.slug}:{self.pseudo_id}" + + def is_prerequisite_satisfied(self): + """Check if participant has completed prerequisite arm(s) for current assignment.""" + if not self.assigned_arm or not self.assigned_arm.required_previous_arm: + return True + return self.completed_arms.filter(pk=self.assigned_arm.required_previous_arm.pk).exists() diff --git a/research/templates/research/bulk_generate.html b/research/templates/research/bulk_generate.html new file mode 100644 index 00000000..9cbfc122 --- /dev/null +++ b/research/templates/research/bulk_generate.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+

Bulk Generate Participants

+

Study: {{ study.name }}

+
+
+ +
+
+
+
Upload Participants
+
+ {% csrf_token %} + {{ form|crispy }} + + Cancel +
+
+
+ +
+
+
Format Reference
+

CSV format (with header row):

+
pseudo_id,candidate_group,email
+P001,Group A,opt@example.com
+P002,Group B,
+
+

JSON format:

+
[
+  {"pseudo_id": "P001", "candidate_group": "A"},
+  {"pseudo_id": "P002", "candidate_group": "B", "email": "..."}
+]
+

+ Notes: +

    +
  • Existing participants with the same pseudo_id will not be overwritten.
  • +
  • CID credentials are generated when participants complete intake.
  • +
  • email field is optional.
  • +
+

+
+
+
+
+{% endblock %} diff --git a/research/templates/research/participant_entry.html b/research/templates/research/participant_entry.html new file mode 100644 index 00000000..2f75d772 --- /dev/null +++ b/research/templates/research/participant_entry.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +
+

{{ study.name }}

+

Participant ID: {{ participant.pseudo_id }}

+ + {% if study.description %} +
+ {{ study.description|linebreaks }} +
+ {% endif %} + + {% if assigned_packet and start_url and participant.consented %} + +
+
Ready to Start
+

You have been assigned to: {{ participant.assigned_arm.name }}

+

Collection: {{ assigned_packet.name }}

+ + +
+ Your Access Credentials
+ Keep note of these to reaccess your results later:
+ CID: {{ participant.cid_user.cid }}  |  + Passcode: {{ participant.cid_user.passcode }} + {% if participant.email %} +
Results can also be accessed via email: {{ participant.email }} + {% endif %} +
+ + Start +
+ + {% elif participant.assigned_arm %} + +
+
Assignment Complete
+

Refreshing to load your credentials...

+
+ + {% else %} + +
+
Participant Intake
+

+ Complete this form to be assigned to a study packet. + {% if study.collect_demographics %} + We collect anonymous demographic data to support analysis. + {% endif %} +

+
+ {% csrf_token %} + {{ form|crispy }} + +
+
+ {% endif %} +
+{% endblock %} diff --git a/research/templates/research/study_arm_form.html b/research/templates/research/study_arm_form.html new file mode 100644 index 00000000..1c22f92c --- /dev/null +++ b/research/templates/research/study_arm_form.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +
+

{% if arm %}Edit Arm: {{ arm.name }}{% else %}Add Arm{% endif %}

+

Study: {{ study.name }}

+ +
+
+ {% csrf_token %} + {{ form|crispy }} +
+ + Cancel +
+
+
+ + {% if arm and arm.packet %} +
+
Collection Marking Configuration
+

+ Marking guidance and scheme names are now configured on the CaseCollection directly. + This allows the same configuration to be reused across multiple studies. +

+
    +
  • + Marking Scheme: {{ arm.packet.marking_scheme_name|default:'(not set)' }} +
  • +
  • + Max Score Per Case: {{ arm.packet.case_question_max_score|default:'10 (default)' }} +
  • +
  • + Marking Guidance:
    + {{ arm.packet.marking_guidance|default:'(not set)' }} +
  • +
+ + Edit Collection Marking Config + +
+ {% endif %} +
+{% endblock %} diff --git a/research/templates/research/study_detail.html b/research/templates/research/study_detail.html new file mode 100644 index 00000000..c1f61ac5 --- /dev/null +++ b/research/templates/research/study_detail.html @@ -0,0 +1,169 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+

{{ study.name }}

+

Slug: {{ study.slug }} • + {{ study.get_generation_mode_display }} • + {{ study.get_randomisation_mode_display }} +

+
+ +
+ + +
+
Participant Entry URL Template
+

Append the pseudo-anonymised ID to this base URL to share with participants:

+ {{ request.scheme }}://{{ request.get_host }}{% url 'research:participant_entry' study.slug 'PSEUDO_ID' %} +
+ + Participants use CID + passcode generated at intake to access their results. + {% if study.collect_demographics %} + Demographics collected at intake. + {% endif %} + +
+
+ +
+ +
+
+
Arms / Packets
+ {% if arm_rows %} + + + + + + + + + + + + + {% for arm, assigned_count in arm_rows %} + + + + + + + + + {% endfor %} + +
NameCollectionAssignedPrerequisiteMark Scheme
{{ arm.name }} + {{ arm.packet.name }} + {% if arm.packet.marking_scheme_name %} +
{{ arm.packet.marking_scheme_name }} + {% endif %} +
{{ assigned_count }} + {% if arm.required_previous_arm %} + {{ arm.required_previous_arm.name }} + {% else %} + - + {% endif %} + {{ arm.packet.marking_scheme_name|default:'-' }} + Edit +
+ {% else %} +
No packets configured. Add at least one packet before sharing participant links.
+ {% endif %} +
+
+ + +
+
+
Add Arm
+
+ {% csrf_token %} + + {{ arm_form|crispy }} + +
+
+
+
+ + +
+
+
Participants ({{ participants|length }})
+
+ {% if participants %} +
+ + + + + + + + + + + + + + {% for participant in participants %} + + + + + + + + + + {% endfor %} + +
Pseudo IDGroupAssigned ArmCIDEmailConsentedAssigned At
{{ participant.pseudo_id }}{{ participant.candidate_group|default:'-' }} + {% if participant.assigned_arm %} + {{ participant.assigned_arm.name }} + {% else %} + - + {% endif %} + + {% if participant.cid_user %} + {{ participant.cid_user.cid }} + {% else %} + - + {% endif %} + + {% if participant.email %} + {{ participant.email }} + {% else %} + - + {% endif %} + + {% if participant.consented %} + Yes + {% else %} + No + {% endif %} + {{ participant.assigned_at|default:'-' }}
+
+ {% else %} +
No participants yet. + {% if study.generation_mode == 'BULK' %} + Use Bulk Generate to add participants. + {% else %} + Participants will be created automatically when they access the participant URL. + {% endif %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/research/templates/research/study_form.html b/research/templates/research/study_form.html new file mode 100644 index 00000000..c34bbfed --- /dev/null +++ b/research/templates/research/study_form.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} + +{% block content %} +
+

{% if is_create %}Create Research Study{% else %}Edit: {{ study.name }}{% endif %}

+

Configure randomisation and participant intake options. Packets are added on the study detail page.

+ +
+ {% csrf_token %} + {{ form|crispy }} +
+ + {% if study %} + Cancel + {% else %} + Cancel + {% endif %} +
+
+
+{% endblock %} diff --git a/research/templates/research/study_list.html b/research/templates/research/study_list.html new file mode 100644 index 00000000..6f3e4f84 --- /dev/null +++ b/research/templates/research/study_list.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Research Studies

+ + Create Study +
+

Create and manage research study packets that randomise participants to Atlas case collections.

+ + {% if studies %} + + + + + + + + + + + + + {% for study in studies %} + + + + + + + + + {% endfor %} + +
NameSlugStatusRandomisationParticipants
{{ study.name }}{{ study.slug }} + {% if study.active %} + Active + {% else %} + Inactive + {% endif %} + {{ study.get_randomisation_mode_display }}{{ study.participants.count }} + Open +
+ {% else %} +
No studies have been created yet.
+ {% endif %} +
+{% endblock %} diff --git a/research/tests/__init__.py b/research/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/research/urls.py b/research/urls.py new file mode 100644 index 00000000..9b85af29 --- /dev/null +++ b/research/urls.py @@ -0,0 +1,23 @@ +from django.urls import path +from research import views + +app_name = "research" + +urlpatterns = [ + # Study management + path("", views.study_list, name="study_list"), + path("create/", views.study_create, name="study_create"), + path("/", views.study_detail, name="study_detail"), + path("/update/", views.study_update, name="study_update"), + path("/arm//", views.study_arm_update, name="study_arm_update"), + + # Exports + path("/export.csv", views.study_export_csv, name="study_export_csv"), + path("/export.json", views.study_export_json, name="study_export_json"), + + # Bulk generation + path("/bulk-generate/", views.study_bulk_generate, name="study_bulk_generate"), + + # Public participant entry (no login required) + path("run///", views.participant_entry, name="participant_entry"), +] diff --git a/research/views.py b/research/views.py new file mode 100644 index 00000000..de3e48af --- /dev/null +++ b/research/views.py @@ -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, + }, + )