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:
Ross
2026-05-12 21:37:57 +01:00
parent e28bf641b7
commit c94d2fb1a7
28 changed files with 2186 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
default_app_config = 'research.apps.ResearchConfig'
+116
View File
@@ -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(
'<code>{}</code>',
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(
'<strong>CID:</strong> {}<br><strong>Passcode:</strong> <code>{}</code><br><strong>Name:</strong> {}',
obj.cid_user.cid,
obj.cid_user.passcode,
obj.cid_user.name
)
return 'Not yet assigned'
cid_user_info.short_description = 'CID User Information'
+7
View File
@@ -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'
+232
View File
@@ -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('<small class="form-text text-muted">For target-based allocation, provide JSON like: {"arm_id": count, ...}</small>'),
),
),
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('<small class="form-text text-muted">Set order_sequence to enforce ordering. Set required_previous_arm to enforce prerequisites.</small>'),
),
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('<div class="alert alert-info">Provide an email address to reaccess your results later if you forget your passcode.</div>'),
'consented',
HTML('<div class="alert alert-warning"><strong>Consent Required:</strong> You must consent to participate to continue.</div>'),
),
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('<small class="form-text text-muted">CSV format: pseudo_id,candidate_group,email<br>JSON format: [{"pseudo_id": "P001", "candidate_group": "A", "email": "..."}]</small>'),
),
Submit('submit', 'Generate Participants'),
)
+95
View File
@@ -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')},
},
),
]
View File
+231
View File
@@ -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()
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2>Bulk Generate Participants</h2>
<p class="text-muted mb-0">Study: <a href="{% url 'research:study_detail' study.pk %}">{{ study.name }}</a></p>
</div>
</div>
<div class="row g-4">
<div class="col-12 col-lg-6">
<div class="card card-body bg-dark border-secondary">
<h5>Upload Participants</h5>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary mt-2" type="submit">Generate Participants</button>
<a class="btn btn-outline-secondary mt-2 ms-2" href="{% url 'research:study_detail' study.pk %}">Cancel</a>
</form>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card card-body bg-dark border-secondary">
<h5>Format Reference</h5>
<p class="text-muted small">CSV format (with header row):</p>
<pre class="bg-dark border rounded p-2 small">pseudo_id,candidate_group,email
P001,Group A,opt@example.com
P002,Group B,</pre>
<hr class="border-secondary">
<p class="text-muted small">JSON format:</p>
<pre class="bg-dark border rounded p-2 small">[
{"pseudo_id": "P001", "candidate_group": "A"},
{"pseudo_id": "P002", "candidate_group": "B", "email": "..."}
]</pre>
<p class="text-muted small mt-2">
<strong>Notes:</strong>
<ul class="small text-muted">
<li>Existing participants with the same pseudo_id will not be overwritten.</li>
<li>CID credentials are generated when participants complete intake.</li>
<li>email field is optional.</li>
</ul>
</p>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<h2>{{ study.name }}</h2>
<p class="text-muted">Participant ID: <code>{{ participant.pseudo_id }}</code></p>
{% if study.description %}
<div class="card card-body bg-dark border-secondary mb-4">
{{ study.description|linebreaks }}
</div>
{% endif %}
{% if assigned_packet and start_url and participant.consented %}
<!-- Participant has been assigned and completed intake -->
<div class="card card-body bg-dark border-secondary mb-3">
<h5>Ready to Start</h5>
<p class="mb-1">You have been assigned to: <strong>{{ participant.assigned_arm.name }}</strong></p>
<p class="mb-1 text-muted">Collection: {{ assigned_packet.name }}</p>
<!-- Passcode reminder block -->
<div class="alert alert-info mt-3">
<strong>Your Access Credentials</strong><br>
Keep note of these to reaccess your results later:<br>
CID: <code>{{ participant.cid_user.cid }}</code> &nbsp;|&nbsp;
Passcode: <code>{{ participant.cid_user.passcode }}</code>
{% if participant.email %}
<br><small class="text-muted">Results can also be accessed via email: {{ participant.email }}</small>
{% endif %}
</div>
<a class="btn btn-primary" href="{{ start_url }}">Start</a>
</div>
{% elif participant.assigned_arm %}
<!-- Assigned but no start_url — CID not yet assigned -->
<div class="card card-body bg-dark border-secondary mb-3">
<h5>Assignment Complete</h5>
<p class="text-muted">Refreshing to load your credentials...</p>
</div>
{% else %}
<!-- Not yet assigned - show intake form -->
<div class="card card-body bg-dark border-secondary mb-3">
<h5>Participant Intake</h5>
<p class="text-muted">
Complete this form to be assigned to a study packet.
{% if study.collect_demographics %}
We collect anonymous demographic data to support analysis.
{% endif %}
</p>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary mt-2" type="submit">Submit and Get Assignment</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<h2>{% if arm %}Edit Arm: {{ arm.name }}{% else %}Add Arm{% endif %}</h2>
<p class="text-muted">Study: <a href="{% url 'research:study_detail' study.pk %}">{{ study.name }}</a></p>
<div class="card card-body bg-dark border-secondary">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" type="submit">Save Arm</button>
<a class="btn btn-outline-secondary" href="{% url 'research:study_detail' study.pk %}">Cancel</a>
</div>
</form>
</div>
{% if arm and arm.packet %}
<div class="card card-body bg-dark border-secondary mt-3">
<h6>Collection Marking Configuration</h6>
<p class="small text-muted mb-2">
Marking guidance and scheme names are now configured on the <a href="{% url 'atlas:collection_detail' arm.packet.pk %}">CaseCollection</a> directly.
This allows the same configuration to be reused across multiple studies.
</p>
<ul class="list-group list-group-flush bg-transparent">
<li class="list-group-item bg-transparent text-light">
<strong>Marking Scheme:</strong> {{ arm.packet.marking_scheme_name|default:'(not set)' }}
</li>
<li class="list-group-item bg-transparent text-light">
<strong>Max Score Per Case:</strong> {{ arm.packet.case_question_max_score|default:'10 (default)' }}
</li>
<li class="list-group-item bg-transparent text-light">
<strong>Marking Guidance:</strong><br>
<span class="text-muted">{{ arm.packet.marking_guidance|default:'(not set)' }}</span>
</li>
</ul>
<a class="btn btn-sm btn-outline-info mt-2" href="{% url 'atlas:collection_detail' arm.packet.pk %}">
Edit Collection Marking Config
</a>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,169 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2>{{ study.name }}</h2>
<p class="text-muted mb-0">Slug: <code>{{ study.slug }}</code> &bull;
{{ study.get_generation_mode_display }} &bull;
{{ study.get_randomisation_mode_display }}
</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-primary" href="{% url 'research:study_update' study.pk %}">Edit Study</a>
<a class="btn btn-outline-secondary" href="{% url 'research:study_export_csv' study.pk %}">Export CSV</a>
<a class="btn btn-outline-secondary" href="{% url 'research:study_export_json' study.pk %}">Export JSON</a>
<a class="btn btn-outline-info" href="{% url 'research:study_bulk_generate' study.pk %}">Bulk Generate</a>
</div>
</div>
<!-- Participant Entry URL -->
<div class="card card-body bg-dark border-secondary mb-4">
<h5 class="mb-2">Participant Entry URL Template</h5>
<p class="small text-muted mb-2">Append the pseudo-anonymised ID to this base URL to share with participants:</p>
<code>{{ request.scheme }}://{{ request.get_host }}{% url 'research:participant_entry' study.slug 'PSEUDO_ID' %}</code>
<div class="mt-2">
<small class="text-muted">
Participants use CID + passcode generated at intake to access their results.
{% if study.collect_demographics %}
Demographics collected at intake.
{% endif %}
</small>
</div>
</div>
<div class="row g-4">
<!-- Arms -->
<div class="col-12 col-lg-7">
<div class="card card-body bg-dark border-secondary h-100">
<h5>Arms / Packets</h5>
{% if arm_rows %}
<table class="table table-dark table-striped table-sm align-middle">
<thead>
<tr>
<th>Name</th>
<th>Collection</th>
<th>Assigned</th>
<th>Prerequisite</th>
<th>Mark Scheme</th>
<th></th>
</tr>
</thead>
<tbody>
{% for arm, assigned_count in arm_rows %}
<tr>
<td>{{ arm.name }}</td>
<td>
<a href="{% url 'atlas:collection_detail' arm.packet.pk %}">{{ arm.packet.name }}</a>
{% if arm.packet.marking_scheme_name %}
<br><small class="text-muted">{{ arm.packet.marking_scheme_name }}</small>
{% endif %}
</td>
<td>{{ assigned_count }}</td>
<td>
{% if arm.required_previous_arm %}
<span class="badge bg-warning text-dark">{{ arm.required_previous_arm.name }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ arm.packet.marking_scheme_name|default:'-' }}</td>
<td>
<a class="btn btn-sm btn-outline-primary" href="{% url 'research:study_arm_update' study.pk arm.pk %}">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-warning">No packets configured. Add at least one packet before sharing participant links.</div>
{% endif %}
</div>
</div>
<!-- Add Arm -->
<div class="col-12 col-lg-5">
<div class="card card-body bg-dark border-secondary h-100">
<h5>Add Arm</h5>
<form method="post">
{% csrf_token %}
<input type="hidden" name="add_arm" value="1">
{{ arm_form|crispy }}
<button class="btn btn-primary" type="submit">Add Arm</button>
</form>
</div>
</div>
</div>
<!-- Participants -->
<div class="card card-body bg-dark border-secondary mt-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5>Participants ({{ participants|length }})</h5>
</div>
{% if participants %}
<div class="table-responsive">
<table class="table table-dark table-striped table-sm">
<thead>
<tr>
<th>Pseudo ID</th>
<th>Group</th>
<th>Assigned Arm</th>
<th>CID</th>
<th>Email</th>
<th>Consented</th>
<th>Assigned At</th>
</tr>
</thead>
<tbody>
{% for participant in participants %}
<tr>
<td><code>{{ participant.pseudo_id }}</code></td>
<td>{{ participant.candidate_group|default:'-' }}</td>
<td>
{% if participant.assigned_arm %}
{{ participant.assigned_arm.name }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if participant.cid_user %}
<code>{{ participant.cid_user.cid }}</code>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if participant.email %}
{{ participant.email }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if participant.consented %}
<span class="badge bg-success">Yes</span>
{% else %}
<span class="badge bg-danger">No</span>
{% endif %}
</td>
<td>{{ participant.assigned_at|default:'-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info mb-0">No participants yet.
{% if study.generation_mode == 'BULK' %}
Use <a href="{% url 'research:study_bulk_generate' study.pk %}">Bulk Generate</a> to add participants.
{% else %}
Participants will be created automatically when they access the participant URL.
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<h2>{% if is_create %}Create Research Study{% else %}Edit: {{ study.name }}{% endif %}</h2>
<p class="text-muted">Configure randomisation and participant intake options. Packets are added on the study detail page.</p>
<form method="post" class="card card-body bg-dark border-secondary">
{% csrf_token %}
{{ form|crispy }}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" type="submit">Save</button>
{% if study %}
<a class="btn btn-outline-secondary" href="{% url 'research:study_detail' study.pk %}">Cancel</a>
{% else %}
<a class="btn btn-outline-secondary" href="{% url 'research:study_list' %}">Cancel</a>
{% endif %}
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Research Studies</h2>
<a class="btn btn-primary" href="{% url 'research:study_create' %}">+ Create Study</a>
</div>
<p class="text-muted">Create and manage research study packets that randomise participants to Atlas case collections.</p>
{% if studies %}
<table class="table table-dark table-striped table-hover table-sm">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Status</th>
<th>Randomisation</th>
<th>Participants</th>
<th></th>
</tr>
</thead>
<tbody>
{% for study in studies %}
<tr>
<td>{{ study.name }}</td>
<td><code>{{ study.slug }}</code></td>
<td>
{% if study.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td>{{ study.get_randomisation_mode_display }}</td>
<td>{{ study.participants.count }}</td>
<td>
<a class="btn btn-sm btn-outline-primary" href="{% url 'research:study_detail' study.pk %}">Open</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-info">No studies have been created yet.</div>
{% endif %}
</div>
{% endblock %}
View File
+23
View File
@@ -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("<int:pk>/", views.study_detail, name="study_detail"),
path("<int:pk>/update/", views.study_update, name="study_update"),
path("<int:pk>/arm/<int:arm_id>/", views.study_arm_update, name="study_arm_update"),
# Exports
path("<int:pk>/export.csv", views.study_export_csv, name="study_export_csv"),
path("<int:pk>/export.json", views.study_export_json, name="study_export_json"),
# Bulk generation
path("<int:pk>/bulk-generate/", views.study_bulk_generate, name="study_bulk_generate"),
# Public participant entry (no login required)
path("run/<slug:slug>/<str:pseudo_id>/", views.participant_entry, name="participant_entry"),
]
+559
View File
@@ -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,
},
)