HH/apps/projects/models.py
2026-07-05 14:49:45 +03:00

277 lines
9.0 KiB
Python

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