""" Projects models - Quality Improvement (QI) projects tracking This module implements QI project management: - Project tracking - Task management - PDCA cycle phases - Milestone tracking - Outcome measurement """ from django.db import models from django.utils.translation import gettext_lazy as _ from apps.core.models import StatusChoices, TimeStampedModel, UUIDModel class PDCAPhaseChoices(models.TextChoices): PLAN = "plan", _("Plan") DO = "do", _("Do") CHECK = "check", _("Check") ACT = "act", _("Act") class FOCUSPhaseChoices(models.TextChoices): FIND = "find", _("Find") ORGANIZE = "organize", _("Organize") CLARIFY = "clarify", _("Clarify") UNDERSTAND = "understand", _("Understand") SELECT = "select", _("Select") class QIProject(UUIDModel, TimeStampedModel): """ Quality Improvement Project. Tracks improvement initiatives driven by PX feedback. Can also serve as a template for creating new projects. """ name = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True) description = models.TextField() # Template flag - if True, this is a template not an active project is_template = models.BooleanField( default=False, db_index=True, help_text="If True, this is a reusable template, not an active project" ) # Organization hospital = models.ForeignKey( "organizations.Hospital", on_delete=models.CASCADE, related_name="qi_projects", null=True, blank=True, help_text="Null for global templates available to all hospitals", ) departments = models.ManyToManyField( "organizations.Department", blank=True, related_name="qi_projects", help_text="Departments involved in this QI project. Team membership is derived from each department's champion and manager.", ) # Project lead project_lead = models.ForeignKey( "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="led_qi_projects" ) # Creator created_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="created_qi_projects" ) # Team members team_members = models.ManyToManyField("organizations.Staff", blank=True, related_name="qi_project_memberships") # Status status = models.CharField( max_length=20, choices=StatusChoices.choices, default=StatusChoices.PENDING, db_index=True ) # Dates start_date = models.DateField(null=True, blank=True) target_completion_date = models.DateField(null=True, blank=True, db_index=True) actual_completion_date = models.DateField(null=True, blank=True) # Linked to PX Actions (optional) related_actions = models.ManyToManyField("px_action_center.PXAction", blank=True, related_name="qi_projects") # Outcomes outcome_description = models.TextField(blank=True) success_metrics = models.JSONField(default=dict, blank=True, help_text="Success metrics and results") # FOCUS methodology focus_enabled = models.BooleanField( default=True, help_text="Whether to use FOCUS methodology alongside PDCA" ) # Metadata metadata = models.JSONField(default=dict, blank=True) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["hospital", "status", "-created_at"]), models.Index(fields=["is_template", "hospital"]), models.Index(fields=["project_lead", "status"]), models.Index(fields=["status", "target_completion_date"]), ] def __str__(self): if self.is_template: return f"[Template] {self.name}" return f"{self.name} ({self.status})" def get_team_members_staff_ids(self): """Return the set of Staff PKs derived from each linked department's champion (a Staff) and manager (a User -> staff_profile). De-duped; nulls skipped.""" pks = set() for dept in self.departments.all(): if dept.champion_id: pks.add(dept.champion_id) # `Department.manager` is a FK to User; resolve to its Staff profile if present. manager_user_id = getattr(dept, "manager_id", None) if manager_user_id: manager_user = dept.manager if manager_user is not None and hasattr(manager_user, "staff_profile"): sp = manager_user.staff_profile if sp is not None and sp.id: pks.add(sp.id) # Ensure project lead is always considered part of the team if self.project_lead_id: pks.add(self.project_lead_id) return pks def sync_team_members_from_departments(self): """Rebuild the `team_members` M2M from the champion + manager of each linked department (plus the project lead). Idempotent.""" if not self.pk: return from apps.organizations.models import Staff self.team_members.set(Staff.objects.filter(pk__in=self.get_team_members_staff_ids())) def is_team_member(self, user): """True if the given user's staff_profile is a derived team member or the project lead.""" staff_profile = getattr(user, "staff_profile", None) if not staff_profile: return False if self.project_lead_id and self.project_lead_id == staff_profile.id: return True return self.team_members.filter(pk=staff_profile.id).exists() class QIProjectTask(UUIDModel, TimeStampedModel): """ Task within a QI project. """ project = models.ForeignKey(QIProject, on_delete=models.CASCADE, related_name="tasks") pdca_phase = models.ForeignKey( "PDCAPhase", on_delete=models.CASCADE, null=True, blank=True, related_name="tasks", ) focus_phase = models.ForeignKey( "FOCUSPhase", on_delete=models.CASCADE, null=True, blank=True, related_name="tasks", ) title = models.CharField(max_length=500) description = models.TextField(blank=True) # Assignment assigned_to = models.ForeignKey( "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="qi_tasks" ) # Status status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.PENDING) # Dates due_date = models.DateField(null=True, blank=True) completed_date = models.DateField(null=True, blank=True) # Order order = models.IntegerField(default=0) class Meta: ordering = ["project", "order"] def __str__(self): return f"{self.project.name} - {self.title}" class PDCAPhase(UUIDModel, TimeStampedModel): """ PDCA (Plan-Do-Check-Act) phase within a QI project. Each project can have up to 4 phases, one per PDCA stage. """ project = models.ForeignKey(QIProject, on_delete=models.CASCADE, related_name="pdca_phases") phase = models.CharField(max_length=10, choices=PDCAPhaseChoices.choices) title = models.CharField(max_length=300, blank=True) description = models.TextField(blank=True) owner = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="owned_pdca_phases", ) status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.PENDING) start_date = models.DateField(null=True, blank=True) due_date = models.DateField(null=True, blank=True) completed_date = models.DateField(null=True, blank=True) findings = models.TextField(blank=True) order = models.IntegerField(default=0) class Meta: unique_together = [("project", "phase")] ordering = ["project", "order"] def __str__(self): return f"{self.project.name} - {self.get_phase_display()}" class FOCUSPhase(UUIDModel, TimeStampedModel): """ FOCUS (Find, Organize, Clarify, Understand, Select) phase within a QI project. Each project can have up to 5 phases, one per FOCUS stage. """ project = models.ForeignKey(QIProject, on_delete=models.CASCADE, related_name="focus_phases") phase = models.CharField(max_length=15, choices=FOCUSPhaseChoices.choices) title = models.CharField(max_length=300, blank=True) description = models.TextField(blank=True) owner = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="owned_focus_phases", ) status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.PENDING) start_date = models.DateField(null=True, blank=True) due_date = models.DateField(null=True, blank=True) completed_date = models.DateField(null=True, blank=True) findings = models.TextField(blank=True) order = models.IntegerField(default=0) class Meta: unique_together = [("project", "phase")] ordering = ["project", "order"] def __str__(self): return f"{self.project.name} - {self.get_phase_display()}"