""" 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, user__isnull=False).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"""
""" for sp in recipients: email = 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 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()) 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, }, )