HH/apps/projects/ui_views.py
ismail c5f76b3855
Some checks are pending
Build and Push Docker Image / build (push) Waiting to run
updates
2026-05-11 14:45:30 +03:00

1540 lines
55 KiB
Python

"""
QI Projects Console UI views
Provides full CRUD functionality for Quality Improvement projects,
task management, and template handling.
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
from apps.core.decorators import block_source_user
from apps.core.models import StatusChoices
from apps.organizations.models import Hospital
from apps.px_action_center.models import PXAction
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm, TaskTemplateFormSet
from .models import QIProject, QIProjectTask, PDCAPhase, PDCAPhaseChoices, FOCUSPhase, FOCUSPhaseChoices
@block_source_user
@login_required
def project_list(request):
"""QI Projects list view with filtering and pagination"""
# Exclude templates from the list
queryset = (
QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead")
.prefetch_related("team_members", "related_actions")
)
# Apply RBAC filters
user = request.user
# Get selected hospital for PX Admins (from middleware)
selected_hospital = getattr(request, "tenant_hospital", None)
if user.is_px_admin():
# PX Admins see all, but filter by selected hospital if set
if selected_hospital:
queryset = queryset.filter(hospital=selected_hospital)
elif user.hospital:
queryset = queryset.filter(hospital=user.hospital)
# Apply filters
status_filter = request.GET.get("status")
if status_filter:
queryset = queryset.filter(status=status_filter)
hospital_filter = request.GET.get("hospital")
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
# Search
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query)
| Q(description__icontains=search_query)
| Q(name_ar__icontains=search_query)
)
# Ordering
queryset = queryset.order_by("-created_at")
# Pagination
page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
# Get hospitals for filter
hospitals = Hospital.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
# Statistics
stats = {
"total": queryset.count(),
"active": queryset.filter(status="active").count(),
"completed": queryset.filter(status="completed").count(),
"pending": queryset.filter(status="pending").count(),
}
context = {
"page_obj": page_obj,
"projects": page_obj.object_list,
"stats": stats,
"filters": request.GET,
}
return render(request, "projects/project_list.html", context)
@block_source_user
@login_required
def project_detail(request, pk):
"""QI Project detail view with kanban board"""
from django.utils import timezone
project = get_object_or_404(
QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead")
.prefetch_related("team_members", "related_actions", "tasks", "pdca_phases", "focus_phases"),
pk=pk,
)
# Check permission
user = request.user
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to view this project."))
return redirect("projects:project_list")
# Get related actions
related_actions = project.related_actions.all()
# Get PDCA phases (auto-create if missing)
pdca_phases = {}
pdca_tasks = {}
for phase_key, phase_label in PDCAPhaseChoices.choices:
phase_obj, _ = PDCAPhase.objects.get_or_create(
project=project,
phase=phase_key,
defaults={"title": phase_label},
)
pdca_phases[phase_key] = phase_obj
pdca_tasks[phase_key] = phase_obj.tasks.all().order_by("order", "created_at")
# Get FOCUS phases (auto-create if missing)
focus_phases = {}
focus_tasks = {}
for phase_key, phase_label in FOCUSPhaseChoices.choices:
phase_obj, _ = FOCUSPhase.objects.get_or_create(
project=project,
phase=phase_key,
defaults={"title": phase_label},
)
focus_phases[phase_key] = phase_obj
focus_tasks[phase_key] = phase_obj.tasks.all().order_by("order", "created_at")
can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager
today = timezone.now().date()
context = {
"project": project,
"related_actions": related_actions,
"pdca_phases": pdca_phases,
"pdca_phase_choices": PDCAPhaseChoices.choices,
"pdca_tasks": pdca_tasks,
"focus_phases": focus_phases,
"focus_phase_choices": FOCUSPhaseChoices.choices,
"focus_tasks": focus_tasks,
"can_edit": can_edit,
"today": today,
}
return render(request, "projects/project_detail.html", context)
@block_source_user
@login_required
def project_create(request, template_pk=None):
"""Create a new QI Project"""
user = request.user
# Check permission (PX Admin, Hospital Admin, or Department Manager)
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
messages.error(request, _("You don't have permission to create projects."))
return redirect("projects:project_list")
# Check for template parameter (from URL or GET)
template_id = template_pk or request.GET.get("template")
initial_data = {}
template = None
if template_id:
try:
template = QIProject.objects.get(pk=template_id, is_template=True)
initial_data = {
"name": template.name,
"name_ar": template.name_ar,
"description": template.description,
"department": template.department,
"target_completion_date": template.target_completion_date,
}
if not user.is_px_admin() and user.hospital:
initial_data["hospital"] = user.hospital
except QIProject.DoesNotExist:
pass
# Check for PX Action parameter (convert action to project)
action_id = request.GET.get("action")
# Check for related model (from complaint/observation/feedback/inquiry)
related_model = request.GET.get("related_model")
related_id = request.GET.get("related_id")
if action_id:
try:
action = PXAction.objects.get(pk=action_id)
initial_data["name"] = f"QI Project: {action.title}"
initial_data["description"] = action.description
if not user.is_px_admin() and user.hospital:
initial_data["hospital"] = user.hospital
else:
initial_data["hospital"] = action.hospital
except PXAction.DoesNotExist:
pass
elif related_model and related_id:
try:
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get(model=related_model)
model_class = ct.model_class()
if model_class:
obj = model_class.objects.filter(pk=related_id).first()
if obj:
title = getattr(obj, "title", None) or getattr(obj, "subject", None) or ""
initial_data["name"] = f"QI Project: {title}" if title else ""
initial_data["description"] = getattr(obj, "description", "") or ""
if hasattr(obj, "hospital_id") and obj.hospital_id:
if user.is_px_admin():
initial_data["hospital"] = obj.hospital_id
if hasattr(obj, "department_id") and obj.department_id:
initial_data["department"] = obj.department_id
except Exception:
pass
if request.method == "POST":
form = QIProjectForm(request.POST, request=request)
# Check for template in POST data (hidden field)
template_id_post = request.POST.get("template_id")
if template_id_post:
try:
template = QIProject.objects.get(pk=template_id_post, is_template=True)
except QIProject.DoesNotExist:
template = None
if form.is_valid():
project = form.save(commit=False)
project.created_by = user
project.save()
form.save_m2m() # Save many-to-many relationships
# If created from template, copy tasks
task_count = 0
if template:
for template_task in template.tasks.all():
QIProjectTask.objects.create(
project=project,
title=template_task.title,
description=template_task.description,
order=template_task.order,
status="pending",
)
task_count += 1
# If created from action, link it
if action_id:
try:
action = PXAction.objects.get(pk=action_id)
project.related_actions.add(action)
except PXAction.DoesNotExist:
pass
# If created from related model, store link in metadata
related_model_post = request.POST.get("related_model") or related_model
related_id_post = request.POST.get("related_id") or related_id
if related_model_post and related_id_post:
project.metadata = project.metadata or {}
project.metadata["related_model"] = related_model_post
project.metadata["related_id"] = related_id_post
project.save(update_fields=["metadata"])
if template and task_count > 0:
messages.success(
request,
_("QI Project created successfully with %(count)d task(s) from template.") % {"count": task_count},
)
else:
messages.success(request, _("QI Project created successfully."))
return redirect("projects:project_detail", pk=project.pk)
else:
form = QIProjectForm(request=request, initial=initial_data)
context = {
"form": form,
"is_create": True,
"template": template,
"related_model": related_model,
"related_id": related_id,
}
return render(request, "projects/project_form.html", context)
@block_source_user
@login_required
def project_edit(request, pk):
"""Edit an existing QI Project"""
user = request.user
project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to edit this project."))
return redirect("projects:project_list")
# Check edit permission (PX Admin, Hospital Admin, or Department Manager)
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
messages.error(request, _("You don't have permission to edit projects."))
return redirect("projects:project_detail", pk=project.pk)
if request.method == "POST":
form = QIProjectForm(request.POST, instance=project, request=request)
if form.is_valid():
form.save()
messages.success(request, _("QI Project updated successfully."))
return redirect("projects:project_detail", pk=project.pk)
else:
form = QIProjectForm(instance=project, request=request)
context = {
"form": form,
"project": project,
"is_create": False,
}
return render(request, "projects/project_form.html", context)
@block_source_user
@login_required
def project_delete(request, pk):
"""Delete a QI Project"""
user = request.user
project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission (only PX Admin or Hospital Admin can delete)
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to delete projects."))
return redirect("projects:project_detail", pk=project.pk)
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to delete this project."))
return redirect("projects:project_list")
if request.method == "POST":
project_name = project.name
project.delete()
messages.success(request, _('Project "%(name)s" deleted successfully.') % {"name": project_name})
return redirect("projects:project_list")
context = {
"project": project,
}
return render(request, "projects/project_delete_confirm.html", context)
@block_source_user
@login_required
def project_save_as_template(request, pk):
"""Save an existing project and its tasks as a template"""
user = request.user
project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission (only PX Admin or Hospital Admin can create templates)
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to create templates."))
return redirect("projects:project_detail", pk=project.pk)
# Check hospital access
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to create templates from this project."))
return redirect("projects:project_list")
if request.method == "POST":
template_name = request.POST.get("template_name", "").strip()
template_description = request.POST.get("template_description", "").strip()
make_global = request.POST.get("make_global") == "on"
if not template_name:
messages.error(request, _("Please provide a template name."))
return redirect("projects:project_save_as_template", pk=project.pk)
# Create template from project
template = QIProject.objects.create(
name=template_name,
name_ar=template_name, # Can be edited later
description=template_description or project.description,
is_template=True,
# If global, hospital is None; otherwise use project's hospital
hospital=None if make_global else project.hospital,
department=project.department,
status="pending", # Default status for templates
created_by=user,
)
# Copy tasks from project to template
for task in project.tasks.all():
QIProjectTask.objects.create(
project=template,
title=task.title,
description=task.description,
order=task.order,
status="pending", # Reset status for template
)
messages.success(
request,
_('Template "%(name)s" created successfully with %(count)d task(s).')
% {"name": template_name, "count": project.tasks.count()},
)
return redirect("projects:template_list")
context = {
"project": project,
"suggested_name": f"Template: {project.name}",
}
return render(request, "projects/project_save_as_template.html", context)
# =============================================================================
# Task Management Views
# =============================================================================
def _task_redirect(project, task):
"""Helper to redirect to phase detail if task belongs to a phase, else project detail."""
if task and task.pdca_phase:
return redirect("projects:pdca_phase_detail", pk=project.pk, phase=task.pdca_phase.phase)
if task and task.focus_phase:
return redirect("projects:focus_phase_detail", pk=project.pk, phase=task.focus_phase.phase)
return redirect("projects:project_detail", pk=project.pk)
@block_source_user
@login_required
def task_create(request, project_pk, phase=None):
"""Add a new task to a project (optionally within a PDCA or FOCUS phase)"""
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
current_phase = None
phase_type = None
if phase and phase in [c[0] for c in PDCAPhaseChoices.choices]:
current_phase = get_object_or_404(PDCAPhase, phase=phase, project=project)
phase_type = "pdca"
elif phase and phase in [c[0] for c in FOCUSPhaseChoices.choices]:
current_phase = get_object_or_404(FOCUSPhase, phase=phase, project=project)
phase_type = "focus"
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to add tasks to this project."))
return redirect("projects:project_detail", pk=project.pk)
if request.method == "POST":
form = QIProjectTaskForm(request.POST, project=project, current_phase=current_phase, phase_type=phase_type)
if form.is_valid():
task = form.save(commit=False)
task.project = project
task.save()
messages.success(request, _("Task added successfully."))
return _task_redirect(project, task)
else:
form = QIProjectTaskForm(project=project, current_phase=current_phase, phase_type=phase_type)
context = {
"form": form,
"project": project,
"pdca_phase": current_phase if phase_type == "pdca" else None,
"focus_phase": current_phase if phase_type == "focus" else None,
"is_create": True,
}
return render(request, "projects/task_form.html", context)
@block_source_user
@login_required
def task_edit(request, project_pk, task_pk, phase=None):
"""Edit an existing task"""
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
current_phase = None
phase_type = None
if phase and phase in [c[0] for c in PDCAPhaseChoices.choices]:
current_phase = get_object_or_404(PDCAPhase, phase=phase, project=project)
phase_type = "pdca"
elif phase and phase in [c[0] for c in FOCUSPhaseChoices.choices]:
current_phase = get_object_or_404(FOCUSPhase, phase=phase, project=project)
phase_type = "focus"
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to edit tasks in this project."))
return redirect("projects:project_detail", pk=project.pk)
if request.method == "POST":
form = QIProjectTaskForm(request.POST, instance=task, project=project, current_phase=current_phase, phase_type=phase_type)
if form.is_valid():
task = form.save()
# If status changed to completed, set completed_date
if task.status == "completed" and not task.completed_date:
from django.utils import timezone
task.completed_date = timezone.now().date()
task.save()
messages.success(request, _("Task updated successfully."))
return _task_redirect(project, task)
else:
form = QIProjectTaskForm(instance=task, project=project, current_phase=current_phase, phase_type=phase_type)
context = {
"form": form,
"project": project,
"task": task,
"pdca_phase": current_phase if phase_type == "pdca" else None,
"focus_phase": current_phase if phase_type == "focus" else None,
"is_create": False,
}
return render(request, "projects/task_form.html", context)
@block_source_user
@login_required
def task_delete(request, project_pk, task_pk, phase=None):
"""Delete a task"""
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to delete tasks in this project."))
return redirect("projects:project_detail", pk=project.pk)
redirect_target = _task_redirect(project, task)
if request.method == "POST":
task.delete()
messages.success(request, _("Task deleted successfully."))
return redirect_target
context = {
"project": project,
"task": task,
"pdca_phase": task.pdca_phase,
"focus_phase": task.focus_phase,
}
return render(request, "projects/task_delete_confirm.html", context)
@block_source_user
@login_required
def task_toggle_status(request, project_pk, task_pk, phase=None):
"""Quick toggle task status between pending and completed"""
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
messages.error(request, _("You don't have permission to update task status."))
return redirect("projects:project_detail", pk=project.pk)
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to update tasks in this project."))
return redirect("projects:project_detail", pk=project.pk)
from django.utils import timezone
if task.status == "completed":
task.status = "pending"
task.completed_date = None
else:
task.status = "completed"
task.completed_date = timezone.now().date()
task.save()
messages.success(request, _("Task status updated."))
return _task_redirect(project, task)
# =============================================================================
# Template Management Views
# =============================================================================
@block_source_user
@login_required
def template_list(request):
"""List QI Project templates"""
user = request.user
# Only admins can manage templates
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to view templates."))
return redirect("projects:project_list")
queryset = QIProject.objects.filter(is_template=True).select_related("hospital", "department")
# Apply RBAC filters
if not user.is_px_admin():
from django.db.models import Q
queryset = queryset.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
# Search
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query)
| Q(description__icontains=search_query)
| Q(name_ar__icontains=search_query)
)
queryset = queryset.order_by("name")
context = {
"templates": queryset,
"can_create": user.is_px_admin() or user.is_hospital_admin,
"can_edit": user.is_px_admin() or user.is_hospital_admin,
}
return render(request, "projects/template_list.html", context)
@block_source_user
@login_required
def template_detail(request, pk):
"""View template details with tasks"""
user = request.user
# Only admins can view templates
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to view templates."))
return redirect("projects:project_list")
template = get_object_or_404(
QIProject.objects.filter(is_template=True).select_related("hospital", "department"), pk=pk
)
# Check permission for hospital-specific templates
if not user.is_px_admin():
from django.db.models import Q
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to view this template."))
return redirect("projects:template_list")
# Get tasks
tasks = template.tasks.all().order_by("order", "created_at")
context = {
"template": template,
"tasks": tasks,
}
return render(request, "projects/template_detail.html", context)
@block_source_user
@login_required
def template_create(request):
"""Create a new project template"""
user = request.user
# Only admins can create templates
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to create templates."))
return redirect("projects:project_list")
if request.method == "POST":
form = QIProjectTemplateForm(request.POST, request=request)
if form.is_valid():
template = form.save(commit=False)
template.is_template = True
template.created_by = user
template.save()
# Save task templates formset
formset = TaskTemplateFormSet(request.POST, instance=template, prefix="tasktemplate_set")
if formset.is_valid():
formset.save()
messages.success(request, _("Project template created successfully."))
return redirect("projects:template_list")
else:
# Form is invalid, show formset with errors
formset = TaskTemplateFormSet(request.POST, prefix="tasktemplate_set")
else:
form = QIProjectTemplateForm(request=request)
formset = TaskTemplateFormSet(prefix="tasktemplate_set")
context = {
"form": form,
"formset": formset,
"is_create": True,
}
return render(request, "projects/template_form.html", context)
@block_source_user
@login_required
def template_edit(request, pk):
"""Edit an existing project template"""
user = request.user
template = get_object_or_404(QIProject, pk=pk, is_template=True)
# Check permission
if not user.is_px_admin():
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to edit this template."))
return redirect("projects:template_list")
if request.method == "POST":
form = QIProjectTemplateForm(request.POST, instance=template, request=request)
formset = TaskTemplateFormSet(request.POST, instance=template, prefix="tasktemplate_set")
if form.is_valid() and formset.is_valid():
form.save()
formset.save()
messages.success(request, _("Project template updated successfully."))
return redirect("projects:template_list")
else:
form = QIProjectTemplateForm(instance=template, request=request)
formset = TaskTemplateFormSet(instance=template, prefix="tasktemplate_set")
context = {
"form": form,
"formset": formset,
"template": template,
"is_create": False,
}
return render(request, "projects/template_form.html", context)
@block_source_user
@login_required
def template_delete(request, pk):
"""Delete a project template"""
user = request.user
template = get_object_or_404(QIProject, pk=pk, is_template=True)
# Check permission
if not user.is_px_admin():
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to delete this template."))
return redirect("projects:template_list")
if request.method == "POST":
template_name = template.name
template.delete()
messages.success(request, _('Template "%(name)s" deleted successfully.') % {"name": template_name})
return redirect("projects:template_list")
context = {
"template": template,
}
return render(request, "projects/template_delete_confirm.html", context)
# =============================================================================
# PX Action Conversion View
# =============================================================================
@block_source_user
@login_required
def convert_action_to_project(request, action_pk):
"""Convert a PX Action to a QI Project"""
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
messages.error(request, _("You don't have permission to create projects."))
return redirect("px_action_center:action_detail", pk=action_pk)
action = get_object_or_404(PXAction, pk=action_pk)
# Check hospital access
if not user.is_px_admin() and user.hospital and action.hospital != user.hospital:
messages.error(request, _("You don't have permission to convert this action."))
return redirect("px_action_center:action_detail", pk=action_pk)
if request.method == "POST":
form = ConvertToProjectForm(request.POST, request=request, action=action)
if form.is_valid():
# Create project from template or blank
template = form.cleaned_data.get("template")
if template:
# Copy from template
project = QIProject.objects.create(
name=form.cleaned_data["project_name"],
name_ar=template.name_ar,
description=template.description,
hospital=action.hospital,
department=template.department,
project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data["target_completion_date"],
status="pending",
created_by=user,
)
# Copy tasks from template
for template_task in template.tasks.all():
QIProjectTask.objects.create(
project=project,
title=template_task.title,
description=template_task.description,
order=template_task.order,
status="pending",
)
else:
# Create blank project
project = QIProject.objects.create(
name=form.cleaned_data["project_name"],
description=action.description,
hospital=action.hospital,
project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data["target_completion_date"],
status="pending",
created_by=user,
)
# Link to the action
project.related_actions.add(action)
messages.success(request, _("PX Action converted to QI Project successfully."))
return redirect("projects:project_detail", pk=project.pk)
else:
initial_data = {
"project_name": f"QI Project: {action.title}",
}
form = ConvertToProjectForm(request=request, action=action, initial=initial_data)
context = {
"form": form,
"action": action,
}
return render(request, "projects/convert_action.html", context)
@block_source_user
@login_required
def pdca_phase_detail(request, pk, phase):
"""Detail view for a PDCA phase, showing phase info and its tasks"""
if phase not in [c[0] for c in PDCAPhaseChoices.choices]:
messages.error(request, _("Invalid PDCA phase."))
return redirect("projects:project_detail", pk=pk)
project = get_object_or_404(QIProject, pk=pk, is_template=False)
user = request.user
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission."))
return redirect("projects:project_list")
pdca_phase, created = PDCAPhase.objects.get_or_create(
project=project,
phase=phase,
defaults={"title": PDCAPhaseChoices(phase).label},
)
tasks = pdca_phase.tasks.all().order_by("order", "created_at")
can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager
from django.utils import timezone
phase_task_counts = {p.phase: p.tasks.count() for p in project.pdca_phases.prefetch_related("tasks").all()}
context = {
"project": project,
"pdca_phase": pdca_phase,
"phase_key": phase,
"phase_label": PDCAPhaseChoices(phase).label,
"tasks": tasks,
"can_edit": can_edit,
"today": timezone.now().date(),
"pdca_phase_choices": PDCAPhaseChoices.choices,
"phase_task_counts": phase_task_counts,
}
return render(request, "projects/pdca_phase_detail.html", context)
@block_source_user
@login_required
def pdca_phase_edit(request, pk, phase):
"""Edit a PDCA phase for a QI project"""
if phase not in [c[0] for c in PDCAPhaseChoices.choices]:
messages.error(request, _("Invalid PDCA phase."))
return redirect("projects:project_detail", pk=pk)
project = get_object_or_404(QIProject, pk=pk, is_template=False)
user = request.user
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission."))
return redirect("projects:project_list")
can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager
if not can_edit:
messages.error(request, _("You don't have permission to edit this project."))
return redirect("projects:pdca_phase_detail", pk=project.pk, phase=phase)
pdca_phase, created = PDCAPhase.objects.get_or_create(
project=project,
phase=phase,
defaults={"title": PDCAPhaseChoices(phase).label},
)
if request.method == "POST":
pdca_phase.title = request.POST.get("title", pdca_phase.title)
pdca_phase.description = request.POST.get("description", "")
pdca_phase.findings = request.POST.get("findings", "")
pdca_phase.status = request.POST.get("status", StatusChoices.PENDING)
pdca_phase.start_date = request.POST.get("start_date") or None
pdca_phase.due_date = request.POST.get("due_date") or None
owner_id = request.POST.get("owner")
if owner_id:
from apps.accounts.models import User
try:
pdca_phase.owner = User.objects.get(pk=owner_id)
except User.DoesNotExist:
pass
else:
pdca_phase.owner = None
pdca_phase.save()
messages.success(request, _("%(phase)s phase updated successfully.") % {"phase": PDCAPhaseChoices(phase).label})
return redirect("projects:pdca_phase_detail", pk=project.pk, phase=phase)
from apps.accounts.models import User
team_members = project.team_members.all()
if project.project_lead:
team_members = team_members | User.objects.filter(pk=project.project_lead.pk)
context = {
"project": project,
"pdca_phase": pdca_phase,
"phase_key": phase,
"phase_label": PDCAPhaseChoices(phase).label,
"team_members": team_members.distinct(),
"status_choices": StatusChoices.choices,
}
return render(request, "projects/pdca_phase_form.html", context)
@block_source_user
@login_required
def focus_phase_detail(request, pk, phase):
if phase not in [c[0] for c in FOCUSPhaseChoices.choices]:
messages.error(request, _("Invalid FOCUS phase."))
return redirect("projects:project_detail", pk=pk)
project = get_object_or_404(QIProject, pk=pk, is_template=False)
user = request.user
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission."))
return redirect("projects:project_list")
focus_phase, created = FOCUSPhase.objects.get_or_create(
project=project,
phase=phase,
defaults={"title": FOCUSPhaseChoices(phase).label},
)
tasks = focus_phase.tasks.all().order_by("order", "created_at")
can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager
from django.utils import timezone
phase_task_counts = {
p.phase: p.tasks.count() for p in project.focus_phases.prefetch_related("tasks").all()
}
context = {
"project": project,
"focus_phase": focus_phase,
"phase_key": phase,
"phase_label": FOCUSPhaseChoices(phase).label,
"tasks": tasks,
"can_edit": can_edit,
"today": timezone.now().date(),
"focus_phase_choices": FOCUSPhaseChoices.choices,
"phase_task_counts": phase_task_counts,
}
return render(request, "projects/focus_phase_detail.html", context)
@block_source_user
@login_required
def focus_phase_edit(request, pk, phase):
if phase not in [c[0] for c in FOCUSPhaseChoices.choices]:
messages.error(request, _("Invalid FOCUS phase."))
return redirect("projects:project_detail", pk=pk)
project = get_object_or_404(QIProject, pk=pk, is_template=False)
user = request.user
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission."))
return redirect("projects:project_list")
can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager
if not can_edit:
messages.error(request, _("You don't have permission to edit this project."))
return redirect("projects:focus_phase_detail", pk=project.pk, phase=phase)
focus_phase, created = FOCUSPhase.objects.get_or_create(
project=project,
phase=phase,
defaults={"title": FOCUSPhaseChoices(phase).label},
)
if request.method == "POST":
focus_phase.title = request.POST.get("title", focus_phase.title)
focus_phase.description = request.POST.get("description", "")
focus_phase.findings = request.POST.get("findings", "")
focus_phase.status = request.POST.get("status", StatusChoices.PENDING)
focus_phase.start_date = request.POST.get("start_date") or None
focus_phase.due_date = request.POST.get("due_date") or None
owner_id = request.POST.get("owner")
if owner_id:
from apps.accounts.models import User
try:
focus_phase.owner = User.objects.get(pk=owner_id)
except User.DoesNotExist:
pass
else:
focus_phase.owner = None
focus_phase.save()
messages.success(
request, _("%(phase)s phase updated successfully.") % {"phase": FOCUSPhaseChoices(phase).label}
)
return redirect("projects:focus_phase_detail", pk=project.pk, phase=phase)
from apps.accounts.models import User
team_members = project.team_members.all()
if project.project_lead:
team_members = team_members | User.objects.filter(pk=project.project_lead.pk)
context = {
"project": project,
"focus_phase": focus_phase,
"phase_key": phase,
"phase_label": FOCUSPhaseChoices(phase).label,
"team_members": team_members.distinct(),
"status_choices": StatusChoices.choices,
}
return render(request, "projects/focus_phase_form.html", context)
@block_source_user
@login_required
def project_export_excel(request, pk):
"""Export QI project with PDCA data as Excel"""
from django.http import HttpResponse
project = get_object_or_404(QIProject, pk=pk, is_template=False)
user = request.user
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission."))
return redirect("projects:project_list")
from .export_utils import export_project_excel
workbook = export_project_excel(project)
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
filename = f"QI_Project_{project.name.replace(' ', '_')}_{project.pk}.xlsx"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
workbook.save(response)
return response
# =============================================================================
# HTMX Endpoint Views for Kanban Board
# =============================================================================
def _check_project_permission(project, user):
"""Helper to check if user can access project."""
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
return False
return True
def _get_can_edit(user):
"""Helper to check edit permissions."""
return user.is_px_admin() or user.is_hospital_admin or user.is_department_manager
@block_source_user
@login_required
def htmx_task_toggle_status(request, project_pk, task_pk):
"""Toggle task status via HTMX - returns updated task row"""
from django.utils import timezone
from django.http import HttpResponse
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
user = request.user
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
if task.status == "completed":
task.status = "pending"
task.completed_date = None
else:
task.status = "completed"
task.completed_date = timezone.now().date()
task.save()
can_edit = _get_can_edit(user)
today = timezone.now().date()
return render(
request,
"projects/partials/task_row.html",
{"task": task, "project": project, "can_edit": can_edit, "today": today},
)
@block_source_user
@login_required
def htmx_task_delete(request, project_pk, task_pk):
"""Delete task via HTMX - returns empty with HX-Trigger"""
from django.http import HttpResponse
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
user = request.user
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
if not _get_can_edit(user):
return HttpResponse(_("You don't have permission to delete tasks."), status=403)
# Determine which column to refresh
phase_type = "pdca" if task.pdca_phase else "focus"
phase_key = task.pdca_phase.phase if task.pdca_phase else task.focus_phase.phase
task.delete()
response = HttpResponse("")
response["HX-Trigger"] = f'{{"taskDeleted": {{"phase_type": "{phase_type}", "phase": "{phase_key}"}}}}'
return response
@block_source_user
@login_required
def htmx_task_create(request, project_pk, phase_type, phase):
"""Create task via HTMX - GET returns form, POST creates and returns column update"""
from django.http import HttpResponse
from django.utils import timezone
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
user = request.user
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
can_edit = _get_can_edit(user)
if not can_edit:
return HttpResponse(_("You don't have permission to add tasks."), status=403)
# Get or create the phase
current_phase = None
if phase_type == "pdca":
if phase not in [c[0] for c in PDCAPhaseChoices.choices]:
return HttpResponse(_("Invalid phase"), status=400)
current_phase, _ = PDCAPhase.objects.get_or_create(
project=project,
phase=phase,
defaults={"title": PDCAPhaseChoices(phase).label},
)
form_phase_type = "pdca"
elif phase_type == "focus":
if phase not in [c[0] for c in FOCUSPhaseChoices.choices]:
return HttpResponse(_("Invalid phase"), status=400)
current_phase, _ = FOCUSPhase.objects.get_or_create(
project=project,
phase=phase,
defaults={"title": FOCUSPhaseChoices(phase).label},
)
form_phase_type = "focus"
else:
return HttpResponse(_("Invalid phase type"), status=400)
if request.method == "POST":
form = QIProjectTaskForm(
request.POST, project=project, current_phase=current_phase, phase_type=form_phase_type, allow_any_phase=True
)
if form.is_valid():
task = form.save(commit=False)
task.project = project
task.save()
# Determine which phase column to refresh
target_phase_type = phase_type
target_phase_key = phase
if task.pdca_phase:
target_phase_type = "pdca"
target_phase_key = task.pdca_phase.phase
elif task.focus_phase:
target_phase_type = "focus"
target_phase_key = task.focus_phase.phase
# Return the updated task list for the target phase
if target_phase_type == "pdca":
target_phase = get_object_or_404(PDCAPhase, phase=target_phase_key, project=project)
else:
target_phase = get_object_or_404(FOCUSPhase, phase=target_phase_key, project=project)
tasks = target_phase.tasks.all().order_by("order", "created_at")
return render(
request,
"projects/partials/phase_task_list.html",
{
"tasks": tasks,
"project": project,
"can_edit": can_edit,
"today": timezone.now().date(),
"phase_type": target_phase_type,
"phase_key": target_phase_key,
},
)
else:
# Return form with errors
return render(
request,
"projects/partials/task_form_modal.html",
{
"form": form,
"project": project,
"phase_type": phase_type,
"phase": current_phase,
"is_create": True,
},
)
# GET - return form
form = QIProjectTaskForm(project=project, current_phase=current_phase, phase_type=form_phase_type, allow_any_phase=True)
return render(
request,
"projects/partials/task_form_modal.html",
{
"form": form,
"project": project,
"phase_type": phase_type,
"phase": current_phase,
"is_create": True,
},
)
@block_source_user
@login_required
def htmx_task_edit_form(request, project_pk, task_pk):
"""Edit task via HTMX - GET returns form, POST updates and returns task row"""
from django.http import HttpResponse
from django.utils import timezone
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
user = request.user
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
can_edit = _get_can_edit(user)
if not can_edit:
return HttpResponse(_("You don't have permission to edit tasks."), status=403)
# Determine phase info
current_phase = None
phase_type = None
if task.pdca_phase:
current_phase = task.pdca_phase
phase_type = "pdca"
elif task.focus_phase:
current_phase = task.focus_phase
phase_type = "focus"
if request.method == "POST":
form = QIProjectTaskForm(
request.POST, instance=task, project=project, current_phase=current_phase, phase_type=phase_type, allow_any_phase=True
)
if form.is_valid():
task = form.save()
if task.status == "completed" and not task.completed_date:
task.completed_date = timezone.now().date()
task.save()
today = timezone.now().date()
# Return updated task row
return render(
request,
"projects/partials/task_row.html",
{"task": task, "project": project, "can_edit": can_edit, "today": today},
)
else:
# Return form with errors
return render(
request,
"projects/partials/task_form_modal.html",
{
"form": form,
"project": project,
"task": task,
"phase_type": phase_type,
"phase": current_phase,
"is_create": False,
},
)
# GET - return form
form = QIProjectTaskForm(instance=task, project=project, current_phase=current_phase, phase_type=phase_type, allow_any_phase=True)
return render(
request,
"projects/partials/task_form_modal.html",
{
"form": form,
"project": project,
"task": task,
"phase_type": phase_type,
"phase": current_phase,
"is_create": False,
},
)
@block_source_user
@login_required
def htmx_pdca_column(request, project_pk, phase):
"""Return PDCA column HTML for HTMX refresh"""
from django.http import HttpResponse
from django.utils import timezone
if phase not in [c[0] for c in PDCAPhaseChoices.choices]:
return HttpResponse(_("Invalid phase"), status=400)
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
user = request.user
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
can_edit = _get_can_edit(user)
today = timezone.now().date()
phase_obj, _ = PDCAPhase.objects.get_or_create(
project=project,
phase=phase,
defaults={"title": PDCAPhaseChoices(phase).label},
)
tasks = phase_obj.tasks.all().order_by("order", "created_at")
return render(
request,
"projects/partials/phase_column.html",
{
"project": project,
"phase_obj": phase_obj,
"phase_key": phase,
"phase_label": PDCAPhaseChoices(phase).label,
"tasks": tasks,
"tasks_count": tasks.count(),
"can_edit": can_edit,
"today": today,
"phase_type": "pdca",
},
)
@block_source_user
@login_required
def htmx_focus_column(request, project_pk, phase):
"""Return FOCUS column HTML for HTMX refresh"""
from django.http import HttpResponse
from django.utils import timezone
if phase not in [c[0] for c in FOCUSPhaseChoices.choices]:
return HttpResponse(_("Invalid phase"), status=400)
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
user = request.user
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
can_edit = _get_can_edit(user)
today = timezone.now().date()
phase_obj, _ = FOCUSPhase.objects.get_or_create(
project=project,
phase=phase,
defaults={"title": FOCUSPhaseChoices(phase).label},
)
tasks = phase_obj.tasks.all().order_by("order", "created_at")
return render(
request,
"projects/partials/phase_column.html",
{
"project": project,
"phase_obj": phase_obj,
"phase_key": phase,
"phase_label": FOCUSPhaseChoices(phase).label,
"tasks": tasks,
"tasks_count": tasks.count(),
"can_edit": can_edit,
"today": today,
"phase_type": "focus",
},
)
@block_source_user
@login_required
def htmx_phase_edit_form(request, project_pk, phase_type, phase):
"""Edit phase via HTMX - GET returns form, POST updates and returns header"""
from django.http import HttpResponse
from django.utils import timezone
from apps.accounts.models import User
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
user = request.user
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
can_edit = _get_can_edit(user)
if not can_edit:
return HttpResponse(_("You don't have permission to edit phases."), status=403)
if phase_type == "pdca":
if phase not in [c[0] for c in PDCAPhaseChoices.choices]:
return HttpResponse(_("Invalid phase"), status=400)
phase_obj, created = PDCAPhase.objects.get_or_create(
project=project,
phase=phase,
defaults={"title": PDCAPhaseChoices(phase).label},
)
phase_label = PDCAPhaseChoices(phase).label
elif phase_type == "focus":
if phase not in [c[0] for c in FOCUSPhaseChoices.choices]:
return HttpResponse(_("Invalid phase"), status=400)
phase_obj, created = FOCUSPhase.objects.get_or_create(
project=project,
phase=phase,
defaults={"title": FOCUSPhaseChoices(phase).label},
)
phase_label = FOCUSPhaseChoices(phase).label
else:
return HttpResponse(_("Invalid phase type"), status=400)
if request.method == "POST":
phase_obj.title = request.POST.get("title", phase_obj.title)
phase_obj.description = request.POST.get("description", "")
phase_obj.findings = request.POST.get("findings", "")
phase_obj.status = request.POST.get("status", StatusChoices.PENDING)
phase_obj.start_date = request.POST.get("start_date") or None
phase_obj.due_date = request.POST.get("due_date") or None
owner_id = request.POST.get("owner")
if owner_id:
try:
phase_obj.owner = User.objects.get(pk=owner_id)
except User.DoesNotExist:
pass
else:
phase_obj.owner = None
phase_obj.save()
# Return updated header
tasks = phase_obj.tasks.all().order_by("order", "created_at")
return render(
request,
"projects/partials/phase_header.html",
{
"project": project,
"phase_obj": phase_obj,
"phase_key": phase,
"phase_label": phase_label,
"tasks_count": tasks.count(),
"can_edit": can_edit,
"phase_type": phase_type,
},
)
# GET - return form
team_members = project.team_members.all()
if project.project_lead:
team_members = team_members | User.objects.filter(pk=project.project_lead.pk)
return render(
request,
"projects/partials/phase_form_modal.html",
{
"project": project,
"phase_obj": phase_obj,
"phase_key": phase,
"phase_label": phase_label,
"phase_type": phase_type,
"team_members": team_members.distinct(),
"status_choices": StatusChoices.choices,
},
)