HH/apps/projects/forms.py
ismail badb6a9ebf
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m41s
feat: QI Projects — team-member task management + My Tasks + notifications
Fixed 3 gaps in the QI Projects module:

Gap 1 (critical): team members couldn't manage their own tasks — the toggle
checkbox/edit/delete were gated behind admin-only can_edit. Now:
- _can_manage_task() helper: admins OR the task assignee OR project team members
- task_toggle_status + htmx_task_toggle_status use the new helper
- task_row.html shows the toggle for assignees (task.assigned_to.user_id check)

Gap 2: no "My QI Tasks" view — added /projects/my-tasks/ showing tasks assigned
to the current user across all projects, with toggle links + stats. Sidebar link.

Gap 3: no notification on task assignment — added apps/projects/signals.py
(post_save on QIProjectTask → create_in_app_notification). apps.py ready() wired.

Tested (headed, 11 PASS / 1 FAIL):
- Cross-department team members (Contact Center + different dept) can VIEW the
  project AND toggle their assigned tasks (both PASS)
- My Tasks view loads for team members
- Excel export returns 500 (real bug, reported)
- Project close via edit form needs correct hospital UUID (test harness issue)

Also bundles accumulated in-progress work across complaints, observations,
organizations, templates, and other modules.

Harness: seed_e2e_project + get_e2e_project_state CLI + qi-projects-workflow.spec.ts
2026-06-16 23:55:58 +03:00

449 lines
18 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.core.form_mixins import HospitalFieldMixin
from apps.accounts.models import User
from apps.organizations.models import Department, Hospital
from .models import QIProject, QIProjectTask, PDCAPhase, FOCUSPhase
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",
"department",
"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"
}
),
"department": 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_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": "",
}
),
"team_members": 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": "",
}
),
"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)
# 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["department"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
# Filter staff choices based on hospital
from apps.organizations.models import Staff
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["department"].queryset = Department.objects.none()
self.fields["project_lead"].queryset = Staff.objects.none() if False else []
self.fields["team_members"].queryset = []
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 based on project hospital
if self.project and self.project.hospital:
from apps.organizations.models import Staff
self.fields["assigned_to"].queryset = Staff.objects.filter(
hospital=self.project.hospital, status="active"
).order_by("first_name", "last_name")
else:
self.fields["assigned_to"].queryset = User.objects.none()
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", "department", "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"
}
),
"department": 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.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["department"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
else:
self.fields["department"].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)
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
from apps.organizations.models import Staff
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,
)