2141 lines
79 KiB
Python
2141 lines
79 KiB
Python
"""
|
|
QI Projects Console UI views
|
|
|
|
Provides full CRUD functionality for Quality Improvement projects,
|
|
task management, and template handling.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
|
|
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.http import JsonResponse
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
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, QIProjectTaskAttachment, PDCAPhase, PDCAPhaseChoices, FOCUSPhase, FOCUSPhaseChoices
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _notify_project_team(project):
|
|
"""Email the project lead + team members that a new project was created.
|
|
|
|
Called from the create paths after `team_members` has been populated.
|
|
`NotificationService.send_email` also creates the matching in-app notification
|
|
for each recipient.
|
|
"""
|
|
from django.conf import settings
|
|
from apps.notifications.services import NotificationService
|
|
from apps.organizations.models import Staff
|
|
|
|
site_url = getattr(settings, "SITE_URL", "http://localhost:8000").rstrip("/")
|
|
project_url = f"{site_url}/projects/{project.id}/"
|
|
|
|
# Recipients: project lead + team members (deduped)
|
|
recipient_ids = set()
|
|
if project.project_lead_id:
|
|
recipient_ids.add(project.project_lead_id)
|
|
recipient_ids.update(project.team_members.values_list("id", flat=True))
|
|
if not recipient_ids:
|
|
return
|
|
|
|
recipients = Staff.objects.filter(id__in=recipient_ids).select_related("user")
|
|
|
|
subject = f"New QI Project: {project.name}"
|
|
plain = (
|
|
f"You have been added to the QI project '{project.name}'.\n"
|
|
f"Open: {project_url}"
|
|
)
|
|
html_message = f"""
|
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">New QI Project</h2>
|
|
<p style="margin: 0 0 12px 0;">You have been added to the project team for <strong>{project.name}</strong>.</p>
|
|
<div style="text-align: center; margin: 20px 0;">
|
|
<a href="{project_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Open Project</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
for sp in recipients:
|
|
email = sp.email or getattr(sp.user, "email", None)
|
|
if not email:
|
|
continue
|
|
try:
|
|
NotificationService.send_email(
|
|
email=email,
|
|
subject=subject,
|
|
message=plain,
|
|
html_message=html_message,
|
|
related_object=project,
|
|
metadata={
|
|
"notification_type": "qi_project_created",
|
|
"project_id": str(project.id),
|
|
},
|
|
notification_type="qi_project_created",
|
|
user=sp.user,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to notify {email} for project {project.id}: {e}")
|
|
|
|
|
|
@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
|
|
|
|
# Allow explicit prefill override (e.g. improvement-project note)
|
|
if request.GET.get("description"):
|
|
initial_data["description"] = request.GET.get("description")
|
|
if request.GET.get("name"):
|
|
initial_data["name"] = request.GET.get("name")
|
|
|
|
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
|
|
|
|
# Ensure the project lead is always part of the team (idempotent)
|
|
if project.project_lead_id:
|
|
project.team_members.add(project.project_lead)
|
|
|
|
# Notify the lead + team members that the project was created
|
|
_notify_project_team(project)
|
|
|
|
# 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,
|
|
"team_member_ids_json": "[]",
|
|
}
|
|
|
|
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()
|
|
# Ensure the project lead is always part of the team (idempotent)
|
|
if project.project_lead_id:
|
|
project.team_members.add(project.project_lead)
|
|
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,
|
|
"team_member_ids_json": json.dumps(
|
|
[str(pk) for pk in project.team_members.values_list("id", flat=True)]
|
|
),
|
|
}
|
|
|
|
return render(request, "projects/project_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def department_team_options(request):
|
|
"""AJAX endpoint returning per-department manager/champion + lead candidates.
|
|
|
|
Powers the dynamic team-member panel and the project-lead dropdown on the
|
|
QI project create/edit form. Scoped to the user's hospital (PX admins use
|
|
their tenant_hospital).
|
|
"""
|
|
from apps.accounts.models import User
|
|
from apps.organizations.models import Department, Staff
|
|
|
|
user = request.user
|
|
|
|
if user.is_px_admin():
|
|
hospital_id = getattr(getattr(request, "tenant_hospital", None), "id", None)
|
|
else:
|
|
hospital_id = user.hospital_id
|
|
|
|
dept_ids = request.GET.get("dept_ids", "")
|
|
dept_id_list = [d for d in dept_ids.split(",") if d]
|
|
|
|
if not dept_id_list:
|
|
return JsonResponse({"departments": [], "leads": []})
|
|
|
|
depts = Department.objects.filter(pk__in=dept_id_list, status="active")
|
|
if hospital_id:
|
|
depts = depts.filter(hospital_id=hospital_id)
|
|
depts = depts.select_related("champion", "manager", "manager__staff_profile")
|
|
|
|
dept_data = []
|
|
leads = []
|
|
seen_lead_ids = set()
|
|
|
|
for dept in depts:
|
|
manager_obj = None
|
|
champion_obj = None
|
|
if dept.champion_id:
|
|
champion_obj = {
|
|
"id": str(dept.champion_id),
|
|
"name": dept.champion.get_full_name(),
|
|
}
|
|
if dept.champion_id not in seen_lead_ids:
|
|
seen_lead_ids.add(dept.champion_id)
|
|
leads.append(
|
|
{
|
|
"id": str(dept.champion_id),
|
|
"label": f"{dept.champion.get_full_name()} — Champion, {dept.get_localized_name()}",
|
|
}
|
|
)
|
|
if dept.manager_id and dept.manager is not None:
|
|
sp = getattr(dept.manager, "staff_profile", None)
|
|
if sp and sp.pk:
|
|
manager_obj = {"id": str(sp.pk), "name": sp.get_full_name()}
|
|
if sp.pk not in seen_lead_ids:
|
|
seen_lead_ids.add(sp.pk)
|
|
leads.append(
|
|
{
|
|
"id": str(sp.pk),
|
|
"label": f"{sp.get_full_name()} — Manager, {dept.get_localized_name()}",
|
|
}
|
|
)
|
|
dept_data.append(
|
|
{
|
|
"id": str(dept.id),
|
|
"name": dept.get_localized_name(),
|
|
"manager": manager_obj,
|
|
"champion": champion_obj,
|
|
}
|
|
)
|
|
|
|
# PX Management / PX Employee may also lead (PX-led initiatives)
|
|
if hospital_id:
|
|
px_user_ids = User.objects.filter(groups__name__in=["PX Management", "PX Employee"]).values_list(
|
|
"id", flat=True
|
|
)
|
|
px_staff = Staff.objects.filter(user_id__in=list(px_user_ids), hospital_id=hospital_id)
|
|
for sp in px_staff:
|
|
if sp.pk in seen_lead_ids:
|
|
continue
|
|
seen_lead_ids.add(sp.pk)
|
|
role = "PX Management" if sp.user and sp.user.is_px_management() else "PX Employee"
|
|
leads.append({"id": str(sp.pk), "label": f"{sp.get_full_name()} ({role})"})
|
|
|
|
return JsonResponse({"departments": dept_data, "leads": leads})
|
|
|
|
|
|
@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)
|
|
|
|
|
|
# =============================================================================
|
|
# Task Notes & Attachments
|
|
# =============================================================================
|
|
|
|
|
|
def _can_contribute(project, user):
|
|
"""True if the user may add content (notes/attachments) to a project's tasks.
|
|
|
|
Granted to admins of the same hospital, the project lead, and any team member.
|
|
(Viewers who are only champions-of-a-linked-department can see but not contribute.)
|
|
"""
|
|
if _get_can_edit(user, project):
|
|
return True
|
|
staff_profile = getattr(user, "staff_profile", None)
|
|
return bool(staff_profile and project.team_members.filter(id=staff_profile.id).exists())
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def task_detail(request, task_pk):
|
|
"""Task detail page: shows the task, its notes, and its attachments."""
|
|
from django.contrib.contenttypes.models import ContentType
|
|
|
|
task = get_object_or_404(
|
|
QIProjectTask.objects.select_related("project", "assigned_to", "pdca_phase", "focus_phase"),
|
|
pk=task_pk,
|
|
project__is_template=False,
|
|
)
|
|
project = task.project
|
|
user = request.user
|
|
|
|
if not _check_project_permission(project, user):
|
|
messages.error(request, _("You don't have permission to view this task."))
|
|
return redirect("projects:project_detail", pk=project.pk)
|
|
|
|
ct = ContentType.objects.get_for_model(QIProjectTask)
|
|
context = {
|
|
"task": task,
|
|
"project": project,
|
|
"now": timezone.now(),
|
|
"notes": task.notes.select_related("created_by").all(),
|
|
"content_type_id": ct.id,
|
|
"object_id": task.id,
|
|
"attachments": task.attachments.select_related("uploaded_by").all(),
|
|
"can_contribute": _can_contribute(project, user),
|
|
"can_edit": _get_can_edit(user, project),
|
|
}
|
|
return render(request, "projects/task_detail.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
@require_POST
|
|
def task_attachment_upload_ajax(request, task_pk):
|
|
"""Upload an attachment to a QI task via AJAX. Contributors only."""
|
|
task = get_object_or_404(QIProjectTask, pk=task_pk, project__is_template=False)
|
|
project = task.project
|
|
user = request.user
|
|
|
|
if not _check_project_permission(project, user) or not _can_contribute(project, user):
|
|
return JsonResponse({"success": False, "error": "Access denied"}, status=403)
|
|
|
|
if "file" not in request.FILES:
|
|
return JsonResponse({"success": False, "error": "No file provided"})
|
|
|
|
uploaded_file = request.FILES["file"]
|
|
|
|
# 10 MB cap
|
|
if uploaded_file.size > 10 * 1024 * 1024:
|
|
return JsonResponse({"success": False, "error": "File size must be less than 10MB"})
|
|
|
|
allowed_extensions = [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".jpg", ".jpeg", ".png", ".zip"]
|
|
file_ext = "." + uploaded_file.name.split(".")[-1].lower() if "." in uploaded_file.name else ""
|
|
if file_ext not in allowed_extensions:
|
|
return JsonResponse(
|
|
{"success": False, "error": f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"}
|
|
)
|
|
|
|
description = request.POST.get("description", "").strip()
|
|
try:
|
|
attachment = QIProjectTaskAttachment.objects.create(
|
|
task=task,
|
|
file=uploaded_file,
|
|
description=description,
|
|
uploaded_by=user,
|
|
)
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"attachment": {
|
|
"id": str(attachment.id),
|
|
"filename": attachment.filename,
|
|
"file_url": attachment.file.url,
|
|
"file_size": attachment.file_size,
|
|
"description": attachment.description or "",
|
|
"uploaded_by": user.get_full_name() if user else "Unknown",
|
|
"uploaded_at": attachment.created_at.strftime("%Y-%m-%d %H:%M"),
|
|
},
|
|
"attachment_count": task.attachments.count(),
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to upload QI task attachment for task {task.pk}: {e}")
|
|
return JsonResponse({"success": False, "error": str(e)})
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
@require_POST
|
|
def task_attachment_delete_ajax(request, task_pk, attachment_pk):
|
|
"""Delete a QI task attachment via AJAX. Uploader or contributors."""
|
|
task = get_object_or_404(QIProjectTask, pk=task_pk, project__is_template=False)
|
|
project = task.project
|
|
user = request.user
|
|
|
|
attachment = get_object_or_404(QIProjectTaskAttachment, pk=attachment_pk, task=task)
|
|
|
|
is_uploader = attachment.uploaded_by_id == user.id
|
|
if not (is_uploader or _can_contribute(project, user)):
|
|
return JsonResponse({"success": False, "error": "Access denied"}, status=403)
|
|
|
|
try:
|
|
attachment.file.delete(save=False)
|
|
attachment.delete()
|
|
return JsonResponse({"success": True, "attachment_count": task.attachments.count()})
|
|
except Exception as e:
|
|
logger.warning(f"Failed to delete QI task attachment {attachment_pk}: {e}")
|
|
return JsonResponse({"success": False, "error": str(e)})
|
|
|
|
|
|
# =============================================================================
|
|
# 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
|
|
selected_hospital = getattr(request, "tenant_hospital", None)
|
|
if user.is_px_admin():
|
|
if selected_hospital:
|
|
queryset = queryset.filter(Q(hospital=selected_hospital) | Q(hospital__isnull=True))
|
|
else:
|
|
# hospital_admin path (only px_admin/hospital_admin reach this point — gated above)
|
|
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())
|
|
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,
|
|
)
|
|
|
|
# Explicit-team model: ensure the lead is on the team (no auto-derivation)
|
|
if project.project_lead_id:
|
|
project.team_members.add(project.project_lead)
|
|
|
|
# Link to the action
|
|
project.related_actions.add(action)
|
|
|
|
# Notify the lead + team members that the project was created
|
|
_notify_project_team(project)
|
|
|
|
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,
|
|
explicit team members, or the champion/manager of any 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
|
|
# Champions/managers of any linked department may view the project
|
|
staff_profile = getattr(user, "staff_profile", None)
|
|
dept_q = Q(manager_id=user.id)
|
|
if staff_profile:
|
|
dept_q = dept_q | Q(champion_id=staff_profile.id)
|
|
if project.departments.filter(dept_q).exists():
|
|
return True
|
|
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,
|
|
},
|
|
)
|