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()