HH/apps/projects/forms.py
2026-07-11 19:24:28 +03:00

510 lines
21 KiB
Python

"""
QI Projects Forms
Forms for creating and managing Quality Improvement projects and tasks.
"""
from django import forms
from django.forms import inlineformset_factory
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import User
from apps.core.form_mixins import HospitalFieldMixin
from apps.organizations.models import Department, Staff
from .models import FOCUSPhase, PDCAPhase, QIProject, QIProjectTask, QIProjectTaskAttachment
class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
"""
Form for creating and editing QI Projects.
Hospital field visibility:
- PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital
"""
class Meta:
model = QIProject
fields = [
"name",
"name_ar",
"description",
"hospital",
"departments",
"project_lead",
"team_members",
"status",
"start_date",
"target_completion_date",
"outcome_description",
]
widgets = {
"name": forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("Project name"),
}
),
"name_ar": forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("اسم المشروع"),
"dir": "rtl",
}
),
"description": forms.Textarea(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
"rows": 4,
"placeholder": _("Describe the project objectives and scope..."),
}
),
"hospital": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"departments": forms.SelectMultiple(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
"data-tomselect": "",
"size": "6",
}
),
"project_lead": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
"data-tomselect": "",
}
),
"status": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"start_date": forms.DateInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"type": "date",
}
),
"target_completion_date": forms.DateInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"type": "date",
}
),
"outcome_description": forms.Textarea(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
"rows": 3,
"placeholder": _("Document project outcomes and results..."),
}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Project lead is required on this form (consistent with ConvertToProjectForm)
self.fields["project_lead"].required = True
# Resolve hospital id from data/initial/instance/user (same precedence as before)
hospital_id = None
if self.data.get("hospital"):
hospital_id = self.data.get("hospital")
elif self.initial.get("hospital"):
hospital_id = self.initial.get("hospital")
elif self.instance and self.instance.pk and self.instance.hospital:
hospital_id = self.instance.hospital.id
elif self.user and self.user.is_px_admin():
tenant_hospital = getattr(self.request, "tenant_hospital", None)
if tenant_hospital:
hospital_id = tenant_hospital.id
elif self.user and self.user.hospital:
hospital_id = self.user.hospital.id
if hospital_id:
self.fields["departments"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
# Broad queryset; clean_project_lead / clean_team_members enforce the
# authoritative rules (role-holder of a selected dept, or PX management/employee).
staff_qs = Staff.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("first_name", "last_name")
self.fields["project_lead"].queryset = staff_qs
self.fields["team_members"].queryset = staff_qs
else:
self.fields["departments"].queryset = Department.objects.none()
self.fields["project_lead"].queryset = Staff.objects.none()
self.fields["team_members"].queryset = Staff.objects.none()
def _role_holder_ids_for_departments(self, departments):
"""Return the set of Staff PKs who are champion or manager (via staff_profile)
of any of the given departments."""
ids = set()
if not departments:
return ids
for dept in Department.objects.filter(pk__in=[d.pk for d in departments]):
if dept.champion_id:
ids.add(dept.champion_id)
if dept.manager_id and dept.manager is not None:
sp = getattr(dept.manager, "staff_profile", None)
if sp and sp.pk:
ids.add(sp.pk)
return ids
def clean_project_lead(self):
lead = self.cleaned_data.get("project_lead")
if not lead:
return lead
departments = self.cleaned_data.get("departments") or []
if lead.pk in self._role_holder_ids_for_departments(departments):
return lead
# PX Management / PX Employee may also lead (PX-led initiatives)
if lead.user and (lead.user.is_px_management() or lead.user.is_px_employee()):
return lead
raise forms.ValidationError(
_("Project lead must be the manager or champion of a selected department, "
"or a PX Management / PX Employee staff member.")
)
def clean_team_members(self):
members = self.cleaned_data.get("team_members") or []
departments = self.cleaned_data.get("departments") or []
if not members:
return members
valid_ids = self._role_holder_ids_for_departments(departments)
invalid = [m for m in members if m.pk not in valid_ids]
if invalid:
names = ", ".join(m.get_full_name() for m in invalid)
raise forms.ValidationError(
_("%(names)s is not the manager or champion of a selected department."),
params={"names": names},
)
return members
class QIProjectTaskForm(forms.ModelForm):
"""
Form for creating and editing QI Project tasks.
"""
class Meta:
model = QIProjectTask
fields = ["title", "description", "assigned_to", "status", "due_date", "order", "pdca_phase", "focus_phase"]
widgets = {
"title": forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("Task title"),
}
),
"description": forms.Textarea(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
"rows": 3,
"placeholder": _("Task description..."),
}
),
"assigned_to": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"status": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"due_date": forms.DateInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"type": "date",
}
),
"order": forms.NumberInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"min": 0,
}
),
"pdca_phase": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"focus_phase": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
}
def __init__(self, *args, **kwargs):
self.project = kwargs.pop("project", None)
self.current_phase = kwargs.pop("current_phase", None)
self.phase_type = kwargs.pop("phase_type", None)
self.allow_any_phase = kwargs.pop("allow_any_phase", False)
super().__init__(*args, **kwargs)
# Make order field not required (has default value of 0)
self.fields["order"].required = False
if self.allow_any_phase:
# Show both PDCA and FOCUS dropdowns
if self.project:
self.fields["pdca_phase"].queryset = self.project.pdca_phases.all().order_by("order")
self.fields["focus_phase"].queryset = self.project.focus_phases.all().order_by("order")
if self.current_phase:
if isinstance(self.current_phase, PDCAPhase):
self.fields["pdca_phase"].initial = self.current_phase.pk
elif isinstance(self.current_phase, FOCUSPhase):
self.fields["focus_phase"].initial = self.current_phase.pk
else:
self.fields["pdca_phase"].queryset = PDCAPhase.objects.none()
self.fields["focus_phase"].queryset = FOCUSPhase.objects.none()
elif self.phase_type == "focus":
self.fields["pdca_phase"].queryset = PDCAPhase.objects.none()
self.fields["pdca_phase"].widget = forms.HiddenInput()
if self.project:
self.fields["focus_phase"].queryset = self.project.focus_phases.all().order_by("order")
if self.current_phase:
self.fields["focus_phase"].initial = self.current_phase.pk
else:
self.fields["focus_phase"].queryset = FOCUSPhase.objects.none()
self.fields["focus_phase"].widget = forms.HiddenInput()
else:
if self.project:
self.fields["pdca_phase"].queryset = self.project.pdca_phases.all().order_by("order")
if self.current_phase:
self.fields["pdca_phase"].initial = self.current_phase.pk
else:
self.fields["pdca_phase"].queryset = PDCAPhase.objects.none()
self.fields["pdca_phase"].widget = forms.HiddenInput()
self.fields["focus_phase"].queryset = FOCUSPhase.objects.none()
self.fields["focus_phase"].widget = forms.HiddenInput()
# Filter assigned_to choices to the project's team members
# (explicitly selected managers/champions + the project lead).
if self.project and self.project.pk:
self.fields["assigned_to"].queryset = self.project.team_members.all().order_by("first_name", "last_name")
else:
self.fields["assigned_to"].queryset = Staff.objects.none()
class QIProjectTaskAttachmentForm(forms.ModelForm):
"""File attachment upload for a QI project task."""
class Meta:
model = QIProjectTaskAttachment
fields = ["file", "description"]
widgets = {
"description": forms.Textarea(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
"rows": 2,
"placeholder": _("Optional description..."),
}
),
}
class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
"""
Form for creating and editing QI Project templates.
Hospital field visibility:
- PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital
Templates can be:
- Global (hospital=None) - available to all
- Hospital-specific - available only to that hospital
"""
class Meta:
model = QIProject
fields = ["name", "name_ar", "description", "hospital", "departments", "target_completion_date"]
widgets = {
"name": forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("Template name"),
}
),
"name_ar": forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("اسم القالب"),
"dir": "rtl",
}
),
"description": forms.Textarea(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
"rows": 4,
"placeholder": _("Describe the project template..."),
}
),
"hospital": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"departments": forms.SelectMultiple(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
"data-tomselect": "",
"size": "6",
}
),
"target_completion_date": forms.DateInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"type": "date",
}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make hospital optional for templates (global templates)
self.fields["hospital"].required = False
self.fields["hospital"].empty_label = _("Global (All Hospitals)")
# Filter department choices based on hospital
hospital_id = None
if self.data.get("hospital"):
hospital_id = self.data.get("hospital")
elif self.initial.get("hospital"):
hospital_id = self.initial.get("hospital")
elif self.instance and self.instance.pk and self.instance.hospital:
hospital_id = self.instance.hospital.id
elif self.user and self.user.is_px_admin():
tenant_hospital = getattr(self.request, "tenant_hospital", None)
if tenant_hospital:
hospital_id = tenant_hospital.id
elif self.user and self.user.hospital:
hospital_id = self.user.hospital.id
if hospital_id:
self.fields["departments"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
else:
self.fields["departments"].queryset = Department.objects.none()
class ConvertToProjectForm(forms.Form):
"""
Form for converting a PX Action to a QI Project.
Allows selecting a template and customizing the project details.
"""
template = forms.ModelChoiceField(
queryset=QIProject.objects.none(),
required=False,
empty_label=_("Blank Project"),
label=_("Project Template"),
widget=forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
)
project_name = forms.CharField(
max_length=200,
label=_("Project Name"),
widget=forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("Enter project name"),
}
),
)
project_lead = forms.ModelChoiceField(
queryset=User.objects.none(),
required=True,
label=_("Project Lead"),
widget=forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
)
target_completion_date = forms.DateField(
required=False,
label=_("Target Completion Date"),
widget=forms.DateInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"type": "date",
}
),
)
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
self.user = self.request.user if self.request else None
self.action = kwargs.pop("action", None)
super().__init__(*args, **kwargs)
from apps.organizations.models import Staff
if self.user and self.user.hospital:
# Filter templates by hospital (or global)
from django.db.models import Q
self.fields["template"].queryset = QIProject.objects.filter(
Q(hospital=self.user.hospital) | Q(hospital__isnull=True),
status="template", # We'll add this status or use metadata
).order_by("name")
# Filter project lead by hospital
self.fields["project_lead"].queryset = Staff.objects.filter(
hospital=self.user.hospital, status="active"
).order_by("first_name", "last_name")
else:
self.fields["template"].queryset = QIProject.objects.none()
self.fields["project_lead"].queryset = Staff.objects.none()
# Inline formset for task templates (used with QIProject templates)
class TaskTemplateForm(forms.ModelForm):
"""Simplified form for task templates (no project field needed)"""
class Meta:
model = QIProjectTask
fields = ["title", "description"]
widgets = {
"title": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": _("Task title"),
}
),
"description": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": _("Description"),
}
),
}
TaskTemplateFormSet = inlineformset_factory(
QIProject,
QIProjectTask,
form=TaskTemplateForm,
fields=["title", "description"],
extra=1,
can_delete=True,
)