Files
penracourses/research/models.py
T

228 lines
7.6 KiB
Python

from django.db import models
from django.conf import settings
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from generic.mixins import AuthorMixin
from generic.models import CidUser
from atlas.models import CaseCollection
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)
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.pk}:{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()