""" 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, )