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

1819 lines
67 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.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
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 my_tasks(request):
"""Show actionable tasks assigned to the current user across the system.
Only items where the user needs to DO something:
- QI Project Tasks assigned to them
- Investigation questions they need to answer
- Explanation requests they need to submit
- Complaint department responses (champion needs to respond)
- Complaint response approvals (department manager needs to approve)
"""
from django.utils import timezone
from datetime import timedelta
user = request.user
staff_profile = getattr(user, "staff_profile", None)
today = timezone.now().date()
tasks = []
# 1. QI Project Tasks
if staff_profile:
qi_tasks = (
QIProjectTask.objects.filter(
assigned_to=staff_profile,
project__is_template=False,
status="pending",
)
.select_related("project", "pdca_phase", "focus_phase")
)
for t in qi_tasks:
tasks.append({
"type": "qi_task",
"type_label": "QI Task",
"icon": "folder-kanban",
"color": "blue",
"title": t.title,
"description": t.description[:100] if t.description else "",
"reference": t.project.name,
"url": reverse("projects:project_detail", kwargs={"pk": t.project_id}),
"due_date": t.due_date,
"is_overdue": bool(t.due_date and t.due_date < today),
})
# 2. Investigation Questions (staff needs to answer)
if staff_profile:
from apps.complaints.models import InvestigationResponse
inv_responses = InvestigationResponse.objects.filter(
staff=staff_profile, is_completed=False,
).select_related("investigation__complaint", "investigation__champion")
for r in inv_responses:
c = r.investigation.complaint
tasks.append({
"type": "investigation",
"type_label": "Investigation",
"icon": "search",
"color": "amber",
"title": f"Answer {r.investigation.questions.count()} investigation questions",
"description": c.title or "",
"reference": c.reference_number,
"url": f"/complaints/{c.id}/investigate/respond/{r.token}/",
"due_date": None,
"is_overdue": False,
})
# 3. Pending Explanations (staff needs to submit)
if staff_profile:
from apps.complaints.models import ComplaintExplanation
explanations = ComplaintExplanation.objects.filter(
staff=staff_profile, is_used=False,
).select_related("complaint")
for e in explanations:
c = e.complaint
tasks.append({
"type": "explanation",
"type_label": "Explanation",
"icon": "message-square",
"color": "orange",
"title": "Submit your explanation",
"description": c.title or "",
"reference": c.reference_number,
"url": reverse("complaints:complaint_explanation_form", kwargs={"complaint_id": c.id, "token": e.token}) if e.token else reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
})
# 3b. Active Investigations (champion needs to review/submit reply)
if staff_profile and hasattr(staff_profile, 'champion_departments') and staff_profile.champion_departments.exists():
from apps.complaints.models import ChampionInvestigation
champ_depts = staff_profile.champion_departments.all()
active_invs = ChampionInvestigation.objects.filter(
involved_department__department__in=champ_depts,
status__in=["questions_sent", "answers_received"],
).select_related("complaint", "champion", "explanation").prefetch_related("responses__staff")
for inv in active_invs:
c = inv.complaint
answered = inv.responses.filter(is_completed=True).count()
total = inv.responses.count()
if inv.status == "answers_received":
tasks.append({
"type": "inv_review",
"type_label": "Review",
"icon": "check-circle",
"color": "green",
"title": "Review answers & submit final reply",
"description": c.title or "",
"reference": c.reference_number,
"url": f"/complaints/{c.id}/investigate/review/{inv.explanation.token}/" if inv.explanation and inv.explanation.token else reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
})
else:
tasks.append({
"type": "inv_waiting",
"type_label": "Investigation",
"icon": "search",
"color": "amber",
"title": f"Waiting for staff responses ({answered}/{total} answered)",
"description": c.title or "",
"reference": c.reference_number,
"url": reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
})
# 4. Complaint department response (champion needs to respond — opens modal)
if staff_profile and hasattr(staff_profile, 'champion_departments') and staff_profile.champion_departments.exists():
from apps.complaints.models import ComplaintInvolvedDepartment
champ_depts = staff_profile.champion_departments.all()
pending_responses = ComplaintInvolvedDepartment.objects.filter(
department__in=champ_depts,
sent=True,
response_submitted=False,
).select_related("complaint", "department")
for d in pending_responses:
c = d.complaint
explanation_url = None
from apps.complaints.models import ComplaintExplanation
explanation = ComplaintExplanation.objects.filter(
complaint_id=c.id,
staff__department=d.department,
).first()
if not explanation and d.department.champion:
import secrets as _secrets
explanation = ComplaintExplanation.objects.create(
complaint=c,
staff=d.department.champion,
token=_secrets.token_urlsafe(32),
explanation="",
is_used=False,
)
if explanation:
needs_resubmit = (
d.acceptance_status == "not_acceptable"
or d.manager_review_status == "rejected"
)
if explanation.is_used and needs_resubmit:
import secrets
explanation.is_used = False
explanation.token = secrets.token_urlsafe(32)
explanation.save(update_fields=["is_used", "token"])
if not explanation.is_used:
explanation_url = reverse(
"complaints:complaint_explanation_form",
kwargs={"complaint_id": c.id, "token": explanation.token},
)
tasks.append({
"type": "dept_response",
"type_label": "Dept Response",
"icon": "building-2",
"color": "cyan",
"title": f"Respond for {d.department.name}",
"description": c.title or "",
"reference": c.reference_number,
"url": reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
"modal_type": "complaint",
"modal_pk": str(d.pk),
"modal_url": reverse("complaints:involved_department_response", kwargs={"pk": d.pk}),
"modal_ref": c.reference_number,
"modal_subject": d.department.name,
"explanation_url": explanation_url,
})
# 5. Complaint response approval (department manager needs to approve)
if user.is_department_manager() and user.department:
from apps.complaints.models import ComplaintInvolvedDepartment
pending_approvals = ComplaintInvolvedDepartment.objects.filter(
department=user.department,
response_submitted=True,
manager_review_status="pending",
).select_related("complaint", "department")
for d in pending_approvals:
c = d.complaint
tasks.append({
"type": "dept_approval",
"type_label": "Approval",
"icon": "check-circle",
"color": "purple",
"title": f"Approve response from {d.department.name}",
"description": c.title or "",
"reference": c.reference_number,
"url": reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
})
# Sort: overdue first, then by due date
tasks.sort(key=lambda t: (
not t["is_overdue"],
t["due_date"] or today + timedelta(days=365),
))
pending = len(tasks)
overdue = len([t for t in tasks if t["is_overdue"]])
return render(
request,
"projects/my_tasks.html",
{
"tasks": tasks,
"pending": pending,
"overdue": overdue,
"today": today,
},
)
@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", "project_lead", "project_lead__department")
.prefetch_related("departments", "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.is_hospital_admin():
# Hospital admins see projects in their hospital, scoped by team membership
if user.hospital:
queryset = queryset.filter(hospital=user.hospital)
staff_profile = getattr(user, "staff_profile", None)
if staff_profile:
queryset = queryset.filter(
Q(team_members=staff_profile) | Q(project_lead=staff_profile)
).distinct()
elif user.hospital:
# Other users: same hospital AND must be a team member or project lead
staff_profile = getattr(user, "staff_profile", None)
if staff_profile:
queryset = queryset.filter(hospital=user.hospital).filter(
Q(team_members=staff_profile) | Q(project_lead=staff_profile)
).distinct()
else:
queryset = queryset.none()
# 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)
department_filter = request.GET.get("department")
if department_filter:
queryset = queryset.filter(departments__id=department_filter).distinct()
# 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", "project_lead", "project_lead__department")
.prefetch_related(
"departments", "team_members", "related_actions", "tasks",
"pdca_phases", "focus_phases",
),
pk=pk,
)
# Check permission (admins, project lead, or team members)
user = request.user
if not _check_project_permission(project, user):
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()
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,
"departments": template.departments.all(),
"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["departments"] = [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 _check_project_permission(project, user):
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 _check_project_permission(project, user):
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,
status="pending", # Default status for templates
created_by=user,
)
# Copy linked departments from project to template
template.departments.set(project.departments.all())
# 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 — must have project access + edit rights
if not _check_project_permission(project, user) or not _get_can_edit(user, project):
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 — must have project access + edit rights
if not _check_project_permission(project, user) or not _get_can_edit(user, project):
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 — must have project access + edit rights
if not _check_project_permission(project, user) or not _get_can_edit(user, project):
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
@require_POST
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 _check_project_permission(project, user) or not _can_manage_task(project, task, user):
messages.error(request, _("You don't have permission to update task status."))
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").prefetch_related("departments")
# Apply RBAC filters
if not user.is_px_admin():
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").prefetch_related("departments"),
pk=pk,
)
# Check permission for hospital-specific templates
if not user.is_px_admin():
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,
project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data["target_completion_date"],
status="pending",
created_by=user,
)
# Copy linked departments + tasks from template
project.departments.set(template.departments.all())
project.sync_team_members_from_departments()
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,
)
# Sync team from project lead (no departments in blank conversion)
project.sync_team_members_from_departments()
# 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()
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()
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()
context = {
"project": project,
"pdca_phase": pdca_phase,
"phase_key": phase,
"phase_label": PDCAPhaseChoices(phase).label,
"team_members": team_members,
"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()
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()
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()
context = {
"project": project,
"focus_phase": focus_phase,
"phase_key": phase,
"phase_label": FOCUSPhaseChoices(phase).label,
"team_members": team_members,
"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):
"""Check if user can access (view) a project.
Granted to: PX admins, hospital admins (same hospital), the project lead,
or any derived team member (champion/manager of a linked department).
"""
if user.is_px_admin():
return True
# Hospital admins are scoped to their own hospital
if user.is_hospital_admin():
return not (user.hospital and project.hospital and user.hospital != project.hospital)
# Cross-hospital access is always denied for regular users
if user.hospital and project.hospital and user.hospital != project.hospital:
return False
return project.is_team_member(user)
def _get_can_edit(user, project):
"""Check edit (structural) permissions: admins of the same hospital, or the project lead."""
if user.is_px_admin():
return True
if user.is_hospital_admin():
return not (user.hospital and project.hospital and user.hospital != project.hospital)
staff_profile = getattr(user, "staff_profile", None)
return bool(staff_profile and project.project_lead_id == staff_profile.id)
def _can_manage_task(project, task, user):
"""Check if user can toggle a task status.
True for admins (same hospital), the project lead, or the task's assignee
(so they can check/uncheck their own tasks).
"""
if _get_can_edit(user, project):
return True
staff_profile = getattr(user, "staff_profile", None)
if staff_profile and task.assigned_to_id == staff_profile.id:
return True
return False
@block_source_user
@login_required
@require_POST
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 not _can_manage_task(project, task, 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, project)
can_toggle = _can_manage_task(project, task, user)
today = timezone.now().date()
return render(
request,
"projects/partials/task_row.html",
{"task": task, "project": project, "can_edit": can_edit, "can_toggle": can_toggle, "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, project):
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, project)
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, project)
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, project)
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, project)
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, project)
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()
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,
"status_choices": StatusChoices.choices,
},
)