The inquiry_department_response view excluded dept managers even though they can resolve the inquiry via inquiry_change_status. Now both actions use the same handling_department_id predicate so forwarding locks the old department out consistently.
7706 lines
307 KiB
Python
7706 lines
307 KiB
Python
"""
|
|
Complaints UI views - Server-rendered templates for complaints console
|
|
"""
|
|
|
|
import logging
|
|
|
|
from .workflow_log import build_workflow_log
|
|
|
|
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, Count, Prefetch
|
|
from django.http import HttpResponseForbidden, 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_http_methods
|
|
|
|
from apps.accounts.models import User
|
|
from apps.accounts.services import StaffActivityService
|
|
from apps.core.services import AuditService
|
|
from apps.core.decorators import hospital_admin_required, block_source_user, px_admin_required
|
|
from apps.organizations.models import Department, Hospital, Staff
|
|
from apps.observations.models import Observation
|
|
from apps.px_sources.models import SourceUser, PXSource
|
|
|
|
|
|
def _safe_next_redirect(request, fallback):
|
|
"""Redirect to request.POST['next'] if it's a safe relative URL.
|
|
|
|
Blocks open-redirect attacks. A URL is considered safe only if it starts
|
|
with a single leading slash and is not a protocol-relative form (``//``
|
|
or ``/\\``) which browsers may resolve against a different host.
|
|
Falls back to ``fallback`` (a redirect()/reverse() result) otherwise.
|
|
"""
|
|
next_url = request.POST.get("next") if hasattr(request, "POST") else None
|
|
if (
|
|
next_url
|
|
and next_url.startswith("/")
|
|
and not next_url.startswith("//")
|
|
and not next_url.startswith("/\\")
|
|
):
|
|
return redirect(next_url)
|
|
return fallback
|
|
|
|
from .models import (
|
|
Complaint,
|
|
ComplaintAttachment,
|
|
ComplaintCategory,
|
|
ComplaintExplanation,
|
|
ComplaintSourceType,
|
|
ComplaintStatus,
|
|
ComplaintType,
|
|
ComplaintUpdate,
|
|
Inquiry,
|
|
InquiryAttachment,
|
|
InquiryUpdate,
|
|
ComplaintAdverseAction,
|
|
ComplaintAdverseActionAttachment,
|
|
GovernmentTicket,
|
|
)
|
|
from .services.complaint_service import ComplaintService, ComplaintServiceError
|
|
from .forms import (
|
|
ComplaintForm,
|
|
ComplaintInvolvedDepartmentForm,
|
|
ComplaintInvolvedStaffForm,
|
|
DepartmentResponseForm,
|
|
StaffExplanationForm,
|
|
GovernmentTicketForm,
|
|
)
|
|
|
|
# Set up logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _format_duration(start, end):
|
|
"""Format duration between two datetimes as a human-readable string."""
|
|
if not start or not end:
|
|
return None
|
|
duration = end - start
|
|
total_seconds = int(duration.total_seconds())
|
|
if total_seconds < 60:
|
|
return "1m"
|
|
days = total_seconds // 86400
|
|
hours = (total_seconds % 86400) // 3600
|
|
minutes = (total_seconds % 3600) // 60
|
|
parts = []
|
|
if days > 0:
|
|
parts.append(f"{days}d")
|
|
if hours > 0:
|
|
parts.append(f"{hours}h")
|
|
if minutes > 0 and days == 0:
|
|
parts.append(f"{minutes}m")
|
|
return " ".join(parts) if parts else "1m"
|
|
|
|
|
|
def _build_complaint_stage_timeline(complaint):
|
|
"""Build stage timeline with timestamps and durations for a complaint."""
|
|
from .models import ComplaintExplanation, ComplaintInvolvedDepartment, ComplaintUpdate
|
|
|
|
stages = []
|
|
|
|
status_updates = list(
|
|
ComplaintUpdate.objects.filter(
|
|
complaint=complaint,
|
|
update_type__in=["status_change", "escalation", "assignment"],
|
|
).select_related("created_by").order_by("created_at")
|
|
)
|
|
|
|
def _find_update(new_status=None, msg_contains=None, update_type=None):
|
|
for u in status_updates:
|
|
if update_type and u.update_type != update_type:
|
|
continue
|
|
if new_status and u.new_status != new_status:
|
|
continue
|
|
if msg_contains and msg_contains not in (u.message or ""):
|
|
continue
|
|
return u
|
|
return None
|
|
|
|
def _user_name(user):
|
|
return user.get_full_name() if user else None
|
|
|
|
if complaint.created_at:
|
|
performed_by = _user_name(complaint.created_by) if complaint.created_by else str(_("Patient"))
|
|
stages.append({"label": _("Created"), "timestamp": complaint.created_at, "color": "bg-slate-400", "icon": "plus-circle", "performed_by": performed_by})
|
|
|
|
if complaint.activated_at:
|
|
activate_update = _find_update(msg_contains="activated", update_type="assignment")
|
|
if not activate_update:
|
|
activate_update = _find_update(new_status="in_progress")
|
|
performed_by = _user_name(activate_update.created_by) if activate_update else None
|
|
stages.append({"label": _("Activated"), "timestamp": complaint.activated_at, "color": "bg-blue-500", "icon": "play-circle", "performed_by": performed_by})
|
|
|
|
forwarded_ts = complaint.forwarded_to_dept_at
|
|
if not forwarded_ts:
|
|
earliest_dept = ComplaintInvolvedDepartment.objects.filter(
|
|
complaint=complaint,
|
|
forwarded_at__isnull=False,
|
|
).select_related("department").order_by("forwarded_at").first()
|
|
if earliest_dept:
|
|
forwarded_ts = earliest_dept.forwarded_at
|
|
if forwarded_ts:
|
|
fwd_update = _find_update(msg_contains="sent to")
|
|
performed_by = _user_name(fwd_update.created_by) if fwd_update else None
|
|
stages.append({"label": _("Forwarded to Department"), "timestamp": forwarded_ts, "color": "bg-purple-500", "icon": "send", "performed_by": performed_by})
|
|
|
|
status_update = _find_update(msg_contains="contacted")
|
|
if status_update:
|
|
performed_by = _user_name(status_update.created_by)
|
|
stages.append({"label": _("Contacted"), "timestamp": status_update.created_at, "color": "bg-indigo-500", "icon": "phone", "performed_by": performed_by})
|
|
|
|
if complaint.escalated_at:
|
|
esc_update = _find_update(update_type="escalation")
|
|
performed_by = _user_name(esc_update.created_by) if esc_update else None
|
|
stages.append({"label": _("Escalated"), "timestamp": complaint.escalated_at, "color": "bg-red-500", "icon": "alert-triangle", "performed_by": performed_by})
|
|
|
|
if complaint.escalated_ovr_at:
|
|
performed_by = _user_name(complaint.escalated_ovr_by)
|
|
stages.append({"label": _("OVR Escalated"), "timestamp": complaint.escalated_ovr_at, "color": "bg-rose-600", "icon": "shield-alert", "performed_by": performed_by})
|
|
|
|
earliest_response = complaint.response_date
|
|
first_explanation = None
|
|
if not earliest_response:
|
|
first_explanation = ComplaintExplanation.objects.filter(
|
|
complaint=complaint,
|
|
responded_at__isnull=False,
|
|
).select_related("staff").order_by("responded_at").first()
|
|
if first_explanation:
|
|
earliest_response = first_explanation.responded_at
|
|
if earliest_response:
|
|
performed_by = _user_name(first_explanation.staff) if first_explanation else None
|
|
stages.append({"label": _("Department Responded"), "timestamp": earliest_response, "color": "bg-amber-500", "icon": "message-square", "performed_by": performed_by})
|
|
|
|
if complaint.resolved_at:
|
|
performed_by = _user_name(complaint.resolved_by)
|
|
if not performed_by:
|
|
resolved_update = _find_update(new_status="resolved")
|
|
performed_by = _user_name(resolved_update.created_by) if resolved_update else None
|
|
stages.append({"label": _("Resolved"), "timestamp": complaint.resolved_at, "color": "bg-green-500", "icon": "check-circle", "performed_by": performed_by})
|
|
|
|
if complaint.partially_resolved_at:
|
|
performed_by = _user_name(complaint.partially_resolved_by)
|
|
if not performed_by:
|
|
pr_update = _find_update(new_status="partially_resolved")
|
|
performed_by = _user_name(pr_update.created_by) if pr_update else None
|
|
stages.append({"label": _("Partially Resolved"), "timestamp": complaint.partially_resolved_at, "color": "bg-yellow-500", "icon": "alert-circle", "performed_by": performed_by})
|
|
|
|
if complaint.pending_external_set_at:
|
|
pe_update = _find_update(new_status="pending_external")
|
|
performed_by = _user_name(pe_update.created_by) if pe_update else None
|
|
stages.append({"label": _("Pending External"), "timestamp": complaint.pending_external_set_at, "color": "bg-orange-500", "icon": "clock", "performed_by": performed_by})
|
|
|
|
if complaint.closed_at:
|
|
performed_by = _user_name(complaint.closed_by)
|
|
if not performed_by:
|
|
closed_update = _find_update(new_status="closed")
|
|
performed_by = _user_name(closed_update.created_by) if closed_update else None
|
|
stages.append({"label": _("Closed"), "timestamp": complaint.closed_at, "color": "bg-emerald-600", "icon": "circle-check", "performed_by": performed_by})
|
|
|
|
if complaint.cancelled_at:
|
|
performed_by = _user_name(complaint.cancelled_by)
|
|
if not performed_by:
|
|
cancelled_update = _find_update(new_status="cancelled")
|
|
performed_by = _user_name(cancelled_update.created_by) if cancelled_update else None
|
|
stages.append({"label": _("Cancelled"), "timestamp": complaint.cancelled_at, "color": "bg-rose-500", "icon": "x-circle", "performed_by": performed_by})
|
|
|
|
stages.sort(key=lambda s: s["timestamp"])
|
|
|
|
for i, stage in enumerate(stages):
|
|
stage["duration_from_prev"] = _format_duration(stages[i - 1]["timestamp"], stage["timestamp"]) if i > 0 else None
|
|
|
|
total_time = None
|
|
if len(stages) >= 2:
|
|
total_time = _format_duration(stages[0]["timestamp"], stages[-1]["timestamp"])
|
|
|
|
return {"stages": stages, "total_time": total_time}
|
|
|
|
|
|
def _build_inquiry_stage_timeline(inquiry):
|
|
"""Build stage timeline with timestamps and durations for an inquiry."""
|
|
from .models import InquiryUpdate
|
|
|
|
stages = []
|
|
|
|
status_updates = list(
|
|
InquiryUpdate.objects.filter(
|
|
inquiry=inquiry,
|
|
).select_related("created_by").order_by("created_at")
|
|
)
|
|
|
|
def _find_update(new_status=None, msg_contains=None, update_type=None):
|
|
for u in status_updates:
|
|
if update_type and u.update_type != update_type:
|
|
continue
|
|
if new_status and u.new_status != new_status:
|
|
continue
|
|
if msg_contains and msg_contains not in (u.message or ""):
|
|
continue
|
|
return u
|
|
return None
|
|
|
|
def _user_name(user):
|
|
return user.get_full_name() if user else None
|
|
|
|
if inquiry.created_at:
|
|
performed_by = _user_name(inquiry.created_by) if inquiry.created_by else str(_("Patient"))
|
|
stages.append({"label": _("Created"), "timestamp": inquiry.created_at, "color": "bg-slate-400", "icon": "plus-circle", "performed_by": performed_by})
|
|
|
|
if inquiry.activated_at:
|
|
activate_update = _find_update(msg_contains="activated", update_type="assignment")
|
|
if not activate_update:
|
|
activate_update = _find_update(new_status="in_progress")
|
|
performed_by = _user_name(activate_update.created_by) if activate_update else None
|
|
stages.append({"label": _("Activated"), "timestamp": inquiry.activated_at, "color": "bg-blue-500", "icon": "play-circle", "performed_by": performed_by})
|
|
|
|
if inquiry.transferred_at:
|
|
performed_by = _user_name(inquiry.transferred_by) if hasattr(inquiry, 'transferred_by') else None
|
|
if not performed_by:
|
|
fwd_update = _find_update(update_type="transferred_to_department")
|
|
performed_by = _user_name(fwd_update.created_by) if fwd_update else None
|
|
stages.append({"label": _("Transferred to Department"), "timestamp": inquiry.transferred_at, "color": "bg-purple-500", "icon": "send", "performed_by": performed_by})
|
|
|
|
if inquiry.contacted_at:
|
|
performed_by = _user_name(inquiry.contacted_by) if hasattr(inquiry, 'contacted_by') else None
|
|
stages.append({"label": _("Contacted"), "timestamp": inquiry.contacted_at, "color": "bg-indigo-500", "icon": "phone", "performed_by": performed_by})
|
|
|
|
if inquiry.contacted_nr_at:
|
|
performed_by = _user_name(inquiry.contacted_nr_by) if hasattr(inquiry, 'contacted_nr_by') else None
|
|
stages.append({"label": _("Contacted No Response"), "timestamp": inquiry.contacted_nr_at, "color": "bg-orange-500", "icon": "phone-off", "performed_by": performed_by})
|
|
|
|
if getattr(inquiry, 'dept_response_escalated_at', None):
|
|
esc_update = _find_update(msg_contains="escalat")
|
|
performed_by = _user_name(esc_update.created_by) if esc_update else None
|
|
stages.append({"label": _("Dept Response Escalated"), "timestamp": inquiry.dept_response_escalated_at, "color": "bg-red-500", "icon": "alert-triangle", "performed_by": performed_by})
|
|
|
|
if inquiry.department_responded_at:
|
|
performed_by = _user_name(inquiry.department_responded_by) if hasattr(inquiry, 'department_responded_by') else None
|
|
stages.append({"label": _("Department Responded"), "timestamp": inquiry.department_responded_at, "color": "bg-amber-500", "icon": "message-square", "performed_by": performed_by})
|
|
|
|
if inquiry.responded_at:
|
|
performed_by = _user_name(inquiry.responded_by) if hasattr(inquiry, 'responded_by') else None
|
|
stages.append({"label": _("Response Sent"), "timestamp": inquiry.responded_at, "color": "bg-cyan-500", "icon": "reply", "performed_by": performed_by})
|
|
|
|
_resolved_at = getattr(inquiry, 'resolved_at', None)
|
|
if _resolved_at:
|
|
resolved_update = _find_update(new_status="resolved")
|
|
performed_by = _user_name(resolved_update.created_by) if resolved_update else None
|
|
stages.append({"label": _("Resolved"), "timestamp": _resolved_at, "color": "bg-green-500", "icon": "check-circle", "performed_by": performed_by})
|
|
|
|
_closed_at = getattr(inquiry, 'closed_at', None)
|
|
if _closed_at:
|
|
closed_update = _find_update(new_status="closed")
|
|
performed_by = _user_name(closed_update.created_by) if closed_update else None
|
|
stages.append({"label": _("Closed"), "timestamp": _closed_at, "color": "bg-emerald-600", "icon": "circle-check", "performed_by": performed_by})
|
|
|
|
stages.sort(key=lambda s: s["timestamp"])
|
|
|
|
for i, stage in enumerate(stages):
|
|
stage["duration_from_prev"] = _format_duration(stages[i - 1]["timestamp"], stage["timestamp"]) if i > 0 else None
|
|
|
|
total_time = None
|
|
if len(stages) >= 2:
|
|
total_time = _format_duration(stages[0]["timestamp"], stages[-1]["timestamp"])
|
|
|
|
return {"stages": stages, "total_time": total_time}
|
|
|
|
|
|
def can_manage_complaint(user, complaint):
|
|
return ComplaintService.can_manage(user, complaint)
|
|
|
|
|
|
@login_required
|
|
def complaint_list(request):
|
|
"""
|
|
Complaints list view with advanced filters and pagination.
|
|
|
|
Features:
|
|
- Server-side pagination
|
|
- Advanced filters (status, severity, priority, hospital, department, etc.)
|
|
- Search by title, description, patient MRN
|
|
- Bulk actions support
|
|
- Export capability
|
|
"""
|
|
# Base queryset with optimizations
|
|
queryset = Complaint.objects.select_related(
|
|
"patient",
|
|
"hospital",
|
|
"department",
|
|
"staff",
|
|
"assigned_to",
|
|
"resolved_by",
|
|
"closed_by",
|
|
"source",
|
|
"created_by",
|
|
"domain",
|
|
"category",
|
|
"resolution_survey",
|
|
)
|
|
|
|
# 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():
|
|
if selected_hospital:
|
|
queryset = queryset.filter(hospital=selected_hospital)
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
elif user.is_px_management() or user.is_px_employee():
|
|
if user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
elif user.is_department_manager() and user.department:
|
|
queryset = queryset.filter(department=user.department)
|
|
elif user.is_champion() and user.department:
|
|
queryset = queryset.filter(department=user.department)
|
|
elif user.is_source_user():
|
|
queryset = queryset.filter(created_by=user)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
# Apply filters from request
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
values = [v.strip() for v in status_filter.split(",")]
|
|
queryset = queryset.filter(status__in=values)
|
|
|
|
# Complaint Type filter (complaint vs appreciation)
|
|
complaint_type_filter = request.GET.get("complaint_type")
|
|
if complaint_type_filter:
|
|
queryset = queryset.filter(complaint_type=complaint_type_filter)
|
|
|
|
# Source Type filter (internal vs external)
|
|
source_type_filter = request.GET.get("complaint_source_type")
|
|
if source_type_filter:
|
|
queryset = queryset.filter(complaint_source_type=source_type_filter)
|
|
|
|
# PX Source filter
|
|
px_source_filter = request.GET.get("px_source")
|
|
if px_source_filter:
|
|
queryset = queryset.filter(source_id=px_source_filter)
|
|
|
|
# Domain filter (taxonomy)
|
|
domain_filter = request.GET.get("domain")
|
|
if domain_filter:
|
|
queryset = queryset.filter(domain_id=domain_filter)
|
|
|
|
# Resolution survey filter
|
|
has_survey_filter = request.GET.get("has_survey")
|
|
if has_survey_filter == "yes":
|
|
queryset = queryset.filter(resolution_survey__isnull=False)
|
|
elif has_survey_filter == "no":
|
|
queryset = queryset.filter(resolution_survey__isnull=True)
|
|
|
|
severity_filter = request.GET.get("severity")
|
|
if severity_filter:
|
|
values = [v.strip() for v in severity_filter.split(",")]
|
|
queryset = queryset.filter(severity__in=values)
|
|
|
|
priority_filter = request.GET.get("priority")
|
|
if priority_filter:
|
|
queryset = queryset.filter(priority=priority_filter)
|
|
|
|
category_filter = request.GET.get("category")
|
|
if category_filter:
|
|
queryset = queryset.filter(category=category_filter)
|
|
|
|
source_filter = request.GET.get("source")
|
|
if source_filter:
|
|
queryset = queryset.filter(source=source_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(department_id=department_filter)
|
|
|
|
staff_filter = request.GET.get("staff")
|
|
if staff_filter:
|
|
queryset = queryset.filter(staff_id=staff_filter)
|
|
|
|
assigned_to_filter = request.GET.get("assigned_to")
|
|
if assigned_to_filter:
|
|
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
|
|
|
|
overdue_filter = request.GET.get("is_overdue")
|
|
if overdue_filter == "true":
|
|
queryset = queryset.filter(is_overdue=True)
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(reference_number__icontains=search_query)
|
|
| Q(patient__mrn__icontains=search_query)
|
|
| Q(patient__first_name__icontains=search_query)
|
|
| Q(patient__last_name__icontains=search_query)
|
|
)
|
|
|
|
# Date range filters (created date)
|
|
date_from = request.GET.get("date_from")
|
|
if not date_from and "date_from" not in request.GET:
|
|
date_from = "2026-01-01"
|
|
if date_from:
|
|
queryset = queryset.filter(created_at__gte=date_from)
|
|
|
|
date_to = request.GET.get("date_to")
|
|
if date_to:
|
|
queryset = queryset.filter(created_at__lte=date_to + " 23:59:59" if len(date_to) == 10 else date_to)
|
|
|
|
# Incident date range filters
|
|
incident_from = request.GET.get("incident_from")
|
|
if incident_from:
|
|
queryset = queryset.filter(incident_date__gte=incident_from)
|
|
|
|
incident_to = request.GET.get("incident_to")
|
|
if incident_to:
|
|
queryset = queryset.filter(incident_date__lte=incident_to)
|
|
|
|
# Ordering
|
|
order_by = request.GET.get("order_by", "-created_at")
|
|
queryset = queryset.order_by(order_by)
|
|
|
|
# Annotate each complaint with can_edit flag
|
|
for complaint in queryset:
|
|
complaint.can_edit = ComplaintService.can_manage(user, complaint)
|
|
|
|
# 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 filter options
|
|
hospitals = Hospital.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
hospitals = hospitals.filter(id=user.hospital.id)
|
|
|
|
departments = Department.objects.filter(status="active")
|
|
if user.is_px_admin():
|
|
if selected_hospital:
|
|
departments = departments.filter(hospital=selected_hospital)
|
|
elif user.hospital:
|
|
departments = departments.filter(hospital=user.hospital)
|
|
|
|
# Get assignable users
|
|
from apps.core.utils import get_assignable_users
|
|
assignable_users = get_assignable_users(user.hospital)
|
|
|
|
# Statistics - more comprehensive
|
|
total_count = queryset.count()
|
|
resolved_count = queryset.filter(status=ComplaintStatus.RESOLVED).count()
|
|
pending_count = queryset.filter(status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS]).count()
|
|
|
|
base_stats = {
|
|
"total": total_count,
|
|
"open": queryset.filter(status=ComplaintStatus.OPEN).count(),
|
|
"in_progress": queryset.filter(status=ComplaintStatus.IN_PROGRESS).count(),
|
|
"resolved": resolved_count,
|
|
"pending": pending_count,
|
|
"resolved_percentage": (resolved_count / total_count * 100) if total_count > 0 else 0,
|
|
"open_percentage": (queryset.filter(status=ComplaintStatus.OPEN).count() / total_count * 100) if total_count > 0 else 0,
|
|
"in_progress_percentage": (queryset.filter(status=ComplaintStatus.IN_PROGRESS).count() / total_count * 100) if total_count > 0 else 0,
|
|
"pending_percentage": (pending_count / total_count * 100) if total_count > 0 else 0,
|
|
"overdue": queryset.filter(is_overdue=True).count(),
|
|
"overdue_percentage": (queryset.filter(is_overdue=True).count() / total_count * 100) if total_count > 0 else 0,
|
|
"complaints": queryset.filter(complaint_type=ComplaintType.COMPLAINT).count(),
|
|
"appreciations": queryset.filter(complaint_type=ComplaintType.APPRECIATION).count(),
|
|
"from_px_sources": queryset.filter(source__isnull=False).count(),
|
|
"internal": queryset.filter(complaint_source_type=ComplaintSourceType.INTERNAL).count(),
|
|
"reopened": queryset.filter(reopened_from__isnull=False).count(),
|
|
"escalated_ovr": queryset.filter(is_escalated_ovr=True).count(),
|
|
}
|
|
|
|
# Get filter options
|
|
from apps.px_sources.models import PXSource
|
|
from apps.complaints.models import ComplaintCategory
|
|
|
|
px_sources = PXSource.objects.filter(is_active=True)
|
|
|
|
# Get domains for taxonomy filter
|
|
domains = ComplaintCategory.objects.filter(level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True)
|
|
|
|
context = {
|
|
"complaints": page_obj,
|
|
"page_obj": page_obj,
|
|
"stats": base_stats,
|
|
"hospitals": hospitals,
|
|
"departments": departments,
|
|
"assignable_users": assignable_users,
|
|
"px_sources": px_sources,
|
|
"domains": domains,
|
|
"status_choices": ComplaintStatus.choices,
|
|
"complaint_type_choices": ComplaintType.choices,
|
|
"source_type_choices": ComplaintSourceType.choices,
|
|
"filters": request.GET,
|
|
"status_filter": status_filter,
|
|
"severity_filter": severity_filter,
|
|
"priority_filter": priority_filter,
|
|
"category_filter": category_filter,
|
|
"source_filter": source_filter,
|
|
"hospital_filter": hospital_filter,
|
|
"department_filter": department_filter,
|
|
"assigned_to_filter": assigned_to_filter,
|
|
"overdue_filter": overdue_filter,
|
|
"search_query": search_query,
|
|
"date_from": date_from,
|
|
"date_to": date_to or "",
|
|
}
|
|
|
|
return render(request, "complaints/complaint_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def complaint_detail(request, pk):
|
|
"""
|
|
Complaint detail view with timeline, attachments, and actions.
|
|
|
|
Features:
|
|
- Full complaint details
|
|
- Timeline of all updates
|
|
- Attachments management
|
|
- Related surveys and journey
|
|
- Linked PX actions
|
|
- Workflow actions (assign, status change, add note)
|
|
|
|
PERFORMANCE OPTIMIZATIONS:
|
|
- Added missing select_related fields to main query
|
|
- Annotated counts to avoid N+1 queries in template
|
|
- Used prefetched data instead of re-querying
|
|
- Optimized escalation targets query
|
|
- Prefetched explanations with attachments
|
|
"""
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
source_user = SourceUser.objects.filter(user=request.user).first()
|
|
base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html"
|
|
|
|
complaint_queryset = (
|
|
Complaint.objects.select_related(
|
|
"patient",
|
|
"hospital",
|
|
"department",
|
|
"staff",
|
|
"assigned_to",
|
|
"resolved_by",
|
|
"closed_by",
|
|
"resolution_survey",
|
|
"source",
|
|
"created_by",
|
|
"domain",
|
|
"category",
|
|
"subcategory_obj",
|
|
"classification_obj",
|
|
)
|
|
.prefetch_related(
|
|
"attachments",
|
|
"updates__created_by",
|
|
"involved_departments__department",
|
|
"involved_departments__assigned_to",
|
|
"involved_staff__staff__department",
|
|
Prefetch(
|
|
"explanations",
|
|
queryset=ComplaintExplanation.objects.select_related("staff")
|
|
.prefetch_related("attachments")
|
|
.order_by("-created_at"),
|
|
),
|
|
Prefetch(
|
|
"adverse_actions",
|
|
queryset=ComplaintAdverseAction.objects.select_related("reported_by").prefetch_related(
|
|
"involved_staff"
|
|
),
|
|
),
|
|
)
|
|
.annotate(
|
|
updates_count=Count("updates", distinct=True),
|
|
attachments_count=Count("attachments", distinct=True),
|
|
involved_departments_count=Count("involved_departments", distinct=True),
|
|
involved_staff_count=Count("involved_staff", distinct=True),
|
|
explanations_count=Count("explanations", distinct=True),
|
|
adverse_actions_count=Count("adverse_actions", distinct=True),
|
|
)
|
|
)
|
|
|
|
complaint = get_object_or_404(complaint_queryset, pk=pk)
|
|
|
|
ComplaintService.ensure_involved_records(complaint)
|
|
|
|
# Self-heal: if the complaint was sent to its primary department but no CID
|
|
# row exists (legacy data from before the fix), create one now so the primary
|
|
# department appears in the Involved Departments list.
|
|
if complaint.sent_to_department and complaint.department_id:
|
|
if not any(inv.department_id == complaint.department_id
|
|
for inv in complaint.involved_departments.all()):
|
|
from .models import ComplaintInvolvedDepartment
|
|
ComplaintInvolvedDepartment.objects.get_or_create(
|
|
complaint=complaint,
|
|
department=complaint.department,
|
|
defaults={
|
|
"role": "primary",
|
|
"is_primary": True,
|
|
"added_by": request.user,
|
|
"forwarded_at": complaint.forwarded_to_dept_at or timezone.now(),
|
|
"sent": True,
|
|
"sent_at": complaint.sent_to_department_at or timezone.now(),
|
|
},
|
|
)
|
|
if hasattr(complaint, "_prefetched_objects_cache"):
|
|
complaint._prefetched_objects_cache.pop("involved_departments", None)
|
|
|
|
user = request.user
|
|
if not user.is_px_admin():
|
|
if user.is_hospital_admin() and complaint.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to view this complaint.")
|
|
return redirect("complaints:complaint_list")
|
|
elif user.is_department_manager() and complaint.department != user.department:
|
|
messages.error(request, "You don't have permission to view this complaint.")
|
|
return redirect("complaints:complaint_list")
|
|
elif user.hospital and complaint.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to view this complaint.")
|
|
return redirect("complaints:complaint_list")
|
|
|
|
timeline = sorted(complaint.updates.all(), key=lambda x: x.created_at, reverse=True)
|
|
attachments = sorted(complaint.attachments.all(), key=lambda x: x.created_at, reverse=True)
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from apps.px_action_center.models import PXAction
|
|
|
|
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
|
px_actions = PXAction.objects.filter(content_type=complaint_ct, object_id=complaint.id).order_by("-created_at")
|
|
|
|
assignable_users = User.objects.filter(
|
|
is_active=True,
|
|
groups__name__in=["PX Employee", "PX Admin", "PX Management"],
|
|
)
|
|
if complaint.hospital:
|
|
assignable_users = assignable_users.filter(hospital=complaint.hospital)
|
|
assignable_users = assignable_users.distinct().order_by("first_name", "last_name")
|
|
|
|
hospital_departments = Department.objects.none()
|
|
if complaint.hospital:
|
|
from django.db.models import Q
|
|
|
|
hospital_departments = (
|
|
Department.objects.filter(
|
|
hospital=complaint.hospital, status="active",
|
|
)
|
|
.filter(Q(champion__isnull=False) | Q(manager__isnull=False))
|
|
.select_related("champion", "manager", "manager__staff_profile")
|
|
.order_by("name")
|
|
)
|
|
|
|
# Departments the complaint hasn't been sent to yet (drives the send-to-dept modal).
|
|
# Filter to only those the send endpoint can actually deliver to (matches
|
|
# get_champion_and_manager — closes the manager-with-no-email hole).
|
|
from apps.organizations.department_contacts import has_contact_target
|
|
available_departments = hospital_departments.exclude(
|
|
pk__in=complaint.involved_departments.values_list("department_id", flat=True)
|
|
)
|
|
available_departments = [d for d in available_departments if has_contact_target(d)]
|
|
|
|
if complaint.is_active_status and not complaint.is_overdue:
|
|
complaint.check_overdue()
|
|
|
|
explanations = complaint.explanations.all()
|
|
explanation = explanations.first() if explanations else None
|
|
explanation_attachments = explanation.attachments.all() if explanation else []
|
|
|
|
escalation_targets = []
|
|
default_escalation_target = None
|
|
|
|
# Build escalation list from Manager + Deputy Manager of ALL involved departments
|
|
involved_depts = list(
|
|
complaint.involved_departments.select_related("department", "department__manager")
|
|
.exclude(department__isnull=True)
|
|
)
|
|
dept_label_map = {}
|
|
departments_to_check = []
|
|
if complaint.department:
|
|
departments_to_check.append(complaint.department)
|
|
dept_label_map[complaint.department.id] = complaint.department.get_localized_name()
|
|
for idept in involved_depts:
|
|
if idept.department and idept.department.id not in dept_label_map:
|
|
departments_to_check.append(idept.department)
|
|
dept_label_map[idept.department.id] = idept.department.get_localized_name()
|
|
|
|
seen_staff_ids = set()
|
|
for dept in departments_to_check:
|
|
dept_label = dept_label_map.get(dept.id, dept.name)
|
|
|
|
# Deputy Manager (Staff FK)
|
|
if dept.deputy_manager and dept.deputy_manager.id not in seen_staff_ids:
|
|
has_user = bool(dept.deputy_manager.user and dept.deputy_manager.user.is_active)
|
|
if has_user or dept.deputy_manager.email:
|
|
seen_staff_ids.add(dept.deputy_manager.id)
|
|
escalation_targets.append({
|
|
"staff": dept.deputy_manager,
|
|
"has_user": has_user,
|
|
"user_id": str(dept.deputy_manager.user.id) if has_user else "",
|
|
"role_label": _("Deputy Manager"),
|
|
"group": dept_label,
|
|
})
|
|
|
|
# Primary Manager (User FK — bridge via staff_profile)
|
|
if dept.manager:
|
|
manager_staff = getattr(dept.manager, "staff_profile", None)
|
|
if manager_staff and manager_staff.id not in seen_staff_ids:
|
|
seen_staff_ids.add(manager_staff.id)
|
|
escalation_targets.append({
|
|
"staff": manager_staff,
|
|
"has_user": True,
|
|
"user_id": str(dept.manager.id),
|
|
"role_label": _("Manager"),
|
|
"group": dept_label,
|
|
})
|
|
|
|
if escalation_targets:
|
|
default_escalation_target = str(escalation_targets[0]["staff"].id)
|
|
|
|
adverse_actions = complaint.adverse_actions.all()
|
|
|
|
from apps.rca.models import RootCauseAnalysis as RCA
|
|
|
|
linked_rcas = RCA.objects.filter(
|
|
content_type=complaint_ct, object_id=complaint.pk, is_deleted=False
|
|
).select_related("assigned_to", "created_by")
|
|
|
|
stage_timeline = _build_complaint_stage_timeline(complaint)
|
|
|
|
context = {
|
|
"complaint": complaint,
|
|
"timeline": timeline,
|
|
"attachments": attachments,
|
|
"px_actions": px_actions,
|
|
"assignable_users": assignable_users,
|
|
"send_to_users": User.objects.filter(
|
|
is_active=True, hospital=complaint.hospital
|
|
).select_related("department").order_by("first_name", "last_name"),
|
|
"status_choices": ComplaintStatus.choices,
|
|
"base_layout": base_layout,
|
|
"source_user": source_user,
|
|
"can_edit": can_manage_complaint(user, complaint),
|
|
"can_manage_actions": (
|
|
user.is_px_admin()
|
|
or (user.is_hospital_admin() and user.hospital == complaint.hospital)
|
|
or (complaint.assigned_to == user)
|
|
or (
|
|
not complaint.activated_at
|
|
and (user.is_px_management() or user.is_px_employee())
|
|
and user.hospital == complaint.hospital
|
|
)
|
|
),
|
|
"can_admin": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital),
|
|
"is_active_status": complaint.is_active_status,
|
|
"workflow_steps": {
|
|
"activated": complaint.activated_at is not None,
|
|
"taxonomy_reviewed": complaint.taxonomy_reviewed_at is not None,
|
|
"sent_to_department": complaint.sent_to_department or complaint.forwarded_to_dept_at is not None,
|
|
"department_responded": (
|
|
complaint.explanations.filter(is_used=True).exists()
|
|
or complaint.involved_departments.filter(response_submitted=True).exists()
|
|
),
|
|
"resolved": complaint.status in ("resolved", "closed"),
|
|
"cancelled": complaint.status == "cancelled",
|
|
},
|
|
"dept_response_progress": {
|
|
"responded": complaint.involved_departments.filter(sent=True, response_submitted=True).count(),
|
|
"total": complaint.involved_departments.filter(sent=True).count(),
|
|
"pending": list(
|
|
complaint.involved_departments.filter(
|
|
sent=True, response_submitted=False
|
|
).select_related("department").values_list("department__name", flat=True)
|
|
),
|
|
"responded_list": [
|
|
{
|
|
"name": d.department.get_localized_name() if d.department else "—",
|
|
"at": d.response_submitted_at,
|
|
}
|
|
for d in complaint.involved_departments.filter(
|
|
sent=True, response_submitted=True
|
|
).select_related("department")
|
|
],
|
|
},
|
|
"satisfaction_history": ComplaintUpdate.objects.filter(
|
|
complaint=complaint, message__icontains="satisfaction"
|
|
).order_by("-created_at")[:5],
|
|
"satisfaction_changes_remaining": max(
|
|
0, Complaint.MAX_SATISFACTION_CHANGES - complaint.satisfaction_change_count
|
|
),
|
|
"can_collect_feedback": bool(
|
|
complaint.department
|
|
and complaint.is_active_status
|
|
and (
|
|
(complaint.department.champion_id and complaint.department.champion.user_id == user.id)
|
|
or complaint.department.manager_id == user.id
|
|
)
|
|
),
|
|
"ai_department_suggested": (
|
|
bool(complaint.department)
|
|
and not complaint.involved_departments.filter(department=complaint.department).exists()
|
|
),
|
|
"hospital_departments": hospital_departments,
|
|
"available_departments": available_departments,
|
|
"involved_department_form": ComplaintInvolvedDepartmentForm(complaint=complaint, user=user),
|
|
"workflow_log": build_workflow_log(complaint),
|
|
"involved_staff_form": ComplaintInvolvedStaffForm(complaint=complaint, user=user),
|
|
"explanation": explanation,
|
|
"explanations": explanations,
|
|
"explanation_attachments": explanation_attachments,
|
|
"escalation_targets": escalation_targets,
|
|
"default_escalation_target": default_escalation_target,
|
|
"escalation_email_subject": f"Complaint Escalated - {complaint.reference_number} - {complaint.title or 'N/A'}",
|
|
"escalation_email_body": (
|
|
f"Dear Manager,\n\n"
|
|
f"This complaint has been escalated and requires your immediate attention.\n\n"
|
|
f"Reference: {complaint.reference_number}\n"
|
|
f"Title: {complaint.title or 'N/A'}\n"
|
|
f"Severity: {complaint.get_severity_display()}\n"
|
|
f"Priority: {complaint.get_priority_display()}\n"
|
|
f"Status: {complaint.get_status_display()}\n\n"
|
|
f"Please review and take appropriate action.\n\n"
|
|
f"View: https://{request.get_host()}/organizations/departments/{complaint.department.pk if complaint.department else ''}/"
|
|
),
|
|
"send_to_email_subject": f"Complaint Sent for Response - {complaint.reference_number}",
|
|
"send_to_email_body": (
|
|
f"Dear Team,\n\n"
|
|
f"Complaint #{complaint.reference_number} requires your response.\n\n"
|
|
f"Title: {complaint.title or 'N/A'}\n"
|
|
f"Severity: {complaint.get_severity_display()}\n"
|
|
f"Priority: {complaint.get_priority_display()}\n\n"
|
|
f"{complaint.description or 'No description provided.'}\n\n"
|
|
f"Please review and respond promptly.\n\n"
|
|
f"View: https://{request.get_host()}/complaints/{complaint.pk}/"
|
|
),
|
|
"current_user": user,
|
|
"adverse_actions": adverse_actions,
|
|
"linked_rcas": linked_rcas,
|
|
"stage_timeline": stage_timeline,
|
|
"show_delay_reason_closure": (
|
|
complaint.delay_reason_closure
|
|
or complaint.is_overdue
|
|
or (timezone.now() - complaint.created_at).total_seconds() / 3600 > 72
|
|
or complaint.status in ("closed", "resolved")
|
|
),
|
|
}
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
complaint_ct = ContentType.objects.get_for_model(complaint)
|
|
context["content_type_id"] = complaint_ct.pk
|
|
context["object_id"] = complaint.pk
|
|
context["notes"] = complaint.notes.select_related("created_by").all()
|
|
context["notes_count"] = context["notes"].count()
|
|
|
|
return render(request, "complaints/complaint_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_assign(request, pk):
|
|
"""Assign complaint to user - Admin or currently assigned user"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
user_id = request.POST.get("user_id")
|
|
if not user_id:
|
|
messages.error(request, "Please select a user to assign.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
assignee = User.objects.get(id=user_id)
|
|
old_status = complaint.status
|
|
ComplaintService.assign(complaint, assignee, request.user, request=request)
|
|
except User.DoesNotExist:
|
|
messages.error(request, "User not found.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_complaint_assigned(
|
|
assignee.email if assignee.email else None,
|
|
complaint,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
messages.success(request, f"Complaint assigned to {assignee.get_full_name()}.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def complaint_create(request):
|
|
"""Create new complaint with AI-powered classification"""
|
|
# Determine base layout based on user type
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
source_user = SourceUser.objects.filter(user=request.user).first()
|
|
base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html"
|
|
|
|
if request.method == "POST":
|
|
# Handle form submission
|
|
form = ComplaintForm(request.POST, request=request)
|
|
|
|
if not form.is_valid():
|
|
# Debug: print form errors
|
|
print("Form validation errors:", form.errors)
|
|
messages.error(request, f"Please correct the errors: {form.errors}")
|
|
context = {
|
|
"form": form,
|
|
"base_layout": base_layout,
|
|
"source_user": source_user,
|
|
}
|
|
return render(request, "complaints/complaint_form.html", context)
|
|
|
|
try:
|
|
# Create complaint with AI defaults
|
|
complaint = form.save(commit=False)
|
|
|
|
# Set AI-determined defaults
|
|
complaint.title = "Complaint" # AI will generate title
|
|
# category can be None, AI will determine it
|
|
complaint.subcategory = "" # AI will determine
|
|
|
|
# Set source from form if selected, otherwise from logged-in source user
|
|
if form.cleaned_data.get("source"):
|
|
# User explicitly selected a PX source
|
|
complaint.source = form.cleaned_data["source"]
|
|
elif source_user and source_user.source:
|
|
# Source user is submitting (auto-assign their source)
|
|
complaint.source = source_user.source
|
|
else:
|
|
# Fallback: get or create a 'staff' source
|
|
from apps.px_sources.models import PXSource
|
|
|
|
try:
|
|
source_obj = PXSource.objects.get(code="staff")
|
|
except PXSource.DoesNotExist:
|
|
source_obj = PXSource.objects.create(
|
|
code="staff", name_en="Staff", description="Complaints submitted by staff members"
|
|
)
|
|
complaint.source = source_obj
|
|
|
|
complaint.priority = "medium" # AI will update
|
|
complaint.severity = "medium" # AI will update
|
|
complaint.created_by = request.user
|
|
|
|
# Auto-set priority to high for MOH/CHI complaints
|
|
if complaint.source and complaint.source.name_en:
|
|
source_name = complaint.source.name_en.lower()
|
|
if "moh" in source_name or "chi" in source_name or "council of health" in source_name or "ministry of health" in source_name:
|
|
complaint.priority = "high"
|
|
|
|
# Link to patient if auto-lookup found a match
|
|
patient_id = request.POST.get("patient_id")
|
|
if patient_id:
|
|
try:
|
|
from uuid import UUID
|
|
|
|
UUID(patient_id)
|
|
complaint.patient_id = patient_id
|
|
except (ValueError, AttributeError):
|
|
pass
|
|
|
|
# Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN)
|
|
complaint.save()
|
|
reference_number = complaint.reference_number
|
|
|
|
# Background patient linking (local DB first, then HIS) — avoids blocking the
|
|
# submit when HIS is slow/unreachable.
|
|
if not complaint.patient_id:
|
|
from .tasks import link_complaint_patient
|
|
|
|
link_complaint_patient.delay(
|
|
str(complaint.id), complaint.national_id, complaint.contact_phone
|
|
)
|
|
|
|
comm_req_id = request.POST.get("comm_req")
|
|
if comm_req_id:
|
|
try:
|
|
from apps.px_sources.models import CommunicationRequest
|
|
cr = CommunicationRequest.objects.get(pk=comm_req_id)
|
|
cr.link_to_record(complaint)
|
|
except Exception:
|
|
pass
|
|
|
|
ComplaintService.post_create_hooks(complaint, request.user, request=request)
|
|
|
|
StaffActivityService.log_from_request(
|
|
request,
|
|
activity_type="create",
|
|
description=f"Created complaint {complaint.reference_number}",
|
|
content_object=complaint,
|
|
module="complaints",
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
f"Complaint #{complaint.id} created successfully. AI is analyzing and classifying the complaint.",
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
except ComplaintCategory.DoesNotExist:
|
|
messages.error(request, "Selected category not found.")
|
|
return redirect("complaints:complaint_create")
|
|
|
|
# GET request - show form
|
|
# Check for hospital parameter from URL (for pre-selection)
|
|
initial_data = {}
|
|
hospital_id = request.GET.get("hospital")
|
|
if hospital_id:
|
|
initial_data["hospital"] = hospital_id
|
|
|
|
communication_request = None
|
|
comm_req_id = request.GET.get("comm_req")
|
|
if comm_req_id:
|
|
try:
|
|
from apps.px_sources.models import CommunicationRequest
|
|
cr_data = CommunicationRequest.get_initial_data(comm_req_id)
|
|
communication_request = cr_data["communication_request"]
|
|
for key in ("hospital", "patient_name", "description"):
|
|
if key in cr_data["initial"] and cr_data["initial"][key]:
|
|
initial_data[key] = cr_data["initial"][key]
|
|
except Exception:
|
|
pass
|
|
|
|
form = ComplaintForm(request=request, initial=initial_data)
|
|
|
|
context = {
|
|
"form": form,
|
|
"base_layout": base_layout,
|
|
"source_user": source_user,
|
|
"communication_request": communication_request,
|
|
}
|
|
|
|
return render(request, "complaints/complaint_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def collect_feedback_start(request, pk):
|
|
"""Authenticated entry to 'Collect Feedback' for the dept champion or manager.
|
|
|
|
Bootstraps a ComplaintExplanation token for the acting user and redirects to
|
|
the compose page (same one the emailed token link opens), reusing the per-staff
|
|
question flow.
|
|
"""
|
|
import secrets
|
|
from .models import ComplaintExplanation
|
|
|
|
complaint = get_object_or_404(Complaint.objects.select_related("department"), pk=pk)
|
|
dept = complaint.department
|
|
user = request.user
|
|
|
|
if not dept:
|
|
messages.error(request, _("This complaint has no department assigned."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
# Permission: department champion or manager only
|
|
is_champion = bool(dept.champion_id) and dept.champion.user_id == user.id
|
|
is_manager = dept.manager_id == user.id
|
|
if not (is_champion or is_manager):
|
|
messages.error(request, _("Only the department champion or manager can collect feedback."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
# Resolve the Staff to attach the explanation to
|
|
staff = dept.champion if is_champion else getattr(dept.manager, "staff_profile", None) or dept.champion
|
|
if staff is None:
|
|
messages.error(request, _("No staff profile available to collect feedback."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
ComplaintExplanation.objects.update_or_create(
|
|
complaint=complaint,
|
|
staff=staff,
|
|
defaults={
|
|
"token": token,
|
|
"is_used": False,
|
|
"requested_by": user,
|
|
"submitted_via": "direct",
|
|
},
|
|
)
|
|
return redirect("complaints:champion_start_investigation", complaint_id=complaint.id, token=token)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_send_to(request, pk):
|
|
"""
|
|
Unified AJAX endpoint to send complaint to either a person or department.
|
|
"""
|
|
from django.http import JsonResponse
|
|
from .models import ComplaintInvolvedDepartment
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not can_manage_complaint(user, complaint):
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("You don't have permission to send this complaint.")),
|
|
}, status=403)
|
|
|
|
# Must be activated (in_progress) before it can be sent/assigned
|
|
if complaint.status == "open":
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Activate this complaint before sending it to a department.")),
|
|
}, status=400)
|
|
|
|
recipient_type = request.POST.get("recipient_type", "department")
|
|
note = request.POST.get("note", "").strip()
|
|
email_subject = request.POST.get("email_subject", "").strip()
|
|
email_body = request.POST.get("email_body", "").strip()
|
|
|
|
notify_targets = []
|
|
try:
|
|
if recipient_type == "person":
|
|
person_id = request.POST.get("person_id")
|
|
if not person_id:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Please select a person.")),
|
|
}, status=400)
|
|
|
|
try:
|
|
person = User.objects.get(id=person_id)
|
|
except User.DoesNotExist:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("User not found.")),
|
|
}, status=400)
|
|
|
|
# Assign complaint to person
|
|
ComplaintService.assign(complaint, person, user, request=request)
|
|
|
|
# Send notification
|
|
if person.email:
|
|
send_subject = email_subject or f"Complaint Assigned - {complaint.reference_number}"
|
|
send_body = email_body or f"You have been assigned to complaint #{complaint.reference_number}."
|
|
NotificationService.send_email(
|
|
email=person.email,
|
|
subject=send_subject,
|
|
message=send_body,
|
|
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;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Complaint Assigned</h2>
|
|
<p>You have been assigned to complaint <strong>#{complaint.reference_number}</strong>.</p>
|
|
<p><strong>Title:</strong> {complaint.title or 'N/A'}</p>
|
|
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
|
|
<p><a href="https://{request.get_host()}/complaints/{complaint.pk}/">View Complaint</a></p>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=complaint,
|
|
)
|
|
|
|
message = f"Complaint sent to {person.get_full_name()}."
|
|
|
|
else: # department
|
|
department_id = request.POST.get("department_id")
|
|
if not department_id:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Please select a department.")),
|
|
}, status=400)
|
|
|
|
try:
|
|
department = Department.objects.select_related("champion", "manager").get(id=department_id, status="active")
|
|
except Department.DoesNotExist:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Department not found.")),
|
|
}, status=400)
|
|
|
|
# Auto-target the department's champion and manager (no contact-person picker)
|
|
from apps.organizations.department_contacts import get_champion_and_manager
|
|
import secrets
|
|
|
|
targets = get_champion_and_manager(department)
|
|
if not targets:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending.")),
|
|
}, status=400)
|
|
|
|
now = timezone.now()
|
|
|
|
if complaint.department_id == department.pk:
|
|
# Create/update CID for the primary department too so it appears
|
|
# in the Involved Departments list and the department-detail queries.
|
|
involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create(
|
|
complaint=complaint,
|
|
department=department,
|
|
defaults={
|
|
"role": "primary",
|
|
"is_primary": True,
|
|
"added_by": user,
|
|
"forwarded_at": now,
|
|
"sent": True,
|
|
"sent_at": now,
|
|
},
|
|
)
|
|
if not created:
|
|
involved_dept.forwarded_at = now
|
|
involved_dept.sent = True
|
|
involved_dept.sent_at = now
|
|
involved_dept.routing_status = "sent"
|
|
involved_dept.rejected_at = None
|
|
involved_dept.rejected_by_staff = None
|
|
involved_dept.rejection_reason = ""
|
|
involved_dept.suggested_department = None
|
|
involved_dept.save(update_fields=[
|
|
"forwarded_at", "sent", "sent_at",
|
|
"routing_status", "rejected_at", "rejected_by_staff",
|
|
"rejection_reason", "suggested_department",
|
|
])
|
|
|
|
complaint.sent_to_department = True
|
|
complaint.sent_to_department_at = now
|
|
if not complaint.forwarded_to_dept_at:
|
|
complaint.forwarded_to_dept_at = now
|
|
else:
|
|
involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create(
|
|
complaint=complaint,
|
|
department=department,
|
|
defaults={
|
|
"role": "secondary",
|
|
"added_by": user,
|
|
"forwarded_at": now,
|
|
"sent": True,
|
|
"sent_at": now,
|
|
}
|
|
)
|
|
|
|
if not created:
|
|
involved_dept.forwarded_at = now
|
|
involved_dept.sent = True
|
|
involved_dept.sent_at = now
|
|
# Reset any prior rejection so the champion can act again
|
|
involved_dept.routing_status = "sent"
|
|
involved_dept.rejected_at = None
|
|
involved_dept.rejected_by_staff = None
|
|
involved_dept.rejection_reason = ""
|
|
involved_dept.suggested_department = None
|
|
involved_dept.save(update_fields=[
|
|
"forwarded_at", "sent", "sent_at",
|
|
"routing_status", "rejected_at", "rejected_by_staff",
|
|
"rejection_reason", "suggested_department",
|
|
])
|
|
|
|
complaint.sent_to_department = True
|
|
complaint.sent_to_department_at = complaint.sent_to_department_at or now
|
|
|
|
complaint.explanation_requested = True
|
|
complaint.explanation_requested_at = complaint.explanation_requested_at or now
|
|
|
|
# Notify each recipient (champion + manager): token link + email + SMS
|
|
notified = []
|
|
host = request.get_host()
|
|
for target in targets:
|
|
email = target.get("email") or ""
|
|
phone = target.get("phone") or ""
|
|
staff = target.get("staff")
|
|
tuser = target.get("user")
|
|
label = target.get("label")
|
|
|
|
# Personal no-login explanation link (token) — create for any target with a Staff profile
|
|
link = None
|
|
effective_staff = staff
|
|
if effective_staff is None and tuser is not None:
|
|
effective_staff = getattr(tuser, "staff_profile", None)
|
|
if effective_staff is not None:
|
|
token = secrets.token_urlsafe(32)
|
|
exp_obj, _ = ComplaintExplanation.objects.update_or_create(
|
|
complaint=complaint,
|
|
staff=effective_staff,
|
|
defaults={
|
|
"token": token,
|
|
"is_used": False,
|
|
"requested_by": user,
|
|
"request_message": note,
|
|
"email_sent_at": timezone.now(),
|
|
"submitted_via": "email_link",
|
|
},
|
|
)
|
|
link = f"https://{host}/complaints/{complaint.id}/explain/{token}/"
|
|
|
|
# Save PX-team attachments against this explanation
|
|
from .models import ExplanationAttachment
|
|
for f in request.FILES.getlist("px_attachments"):
|
|
ExplanationAttachment.objects.create(
|
|
explanation=exp_obj,
|
|
file=f,
|
|
filename=f.name,
|
|
file_type=f.content_type,
|
|
file_size=f.size,
|
|
)
|
|
|
|
display_name = (
|
|
staff.get_full_name() if staff is not None
|
|
else (tuser.get_full_name() if tuser else label)
|
|
)
|
|
fallback_link = f"https://{host}/complaints/{complaint.pk}/"
|
|
review_link = link or fallback_link
|
|
|
|
# Collect for async notification — email + SMS are sent by a Celery task
|
|
# (send_department_notification_task) so this request stays fast.
|
|
if email or phone:
|
|
notify_targets.append({
|
|
"email": email,
|
|
"phone": phone,
|
|
"link": review_link,
|
|
"label": label,
|
|
"display_name": display_name,
|
|
})
|
|
|
|
notified.append(f"{display_name} ({label})")
|
|
|
|
message = f"Complaint sent to {department.name} — {', '.join(notified)}."
|
|
|
|
# Optionally record a selected staff member as involved (NOT notified;
|
|
# notification target remains champion/manager only).
|
|
staff_id = request.POST.get("staff_id", "").strip()
|
|
if staff_id:
|
|
from .models import ComplaintInvolvedStaff
|
|
staff_member = Staff.objects.filter(pk=staff_id, department=department).first()
|
|
if staff_member:
|
|
ComplaintInvolvedStaff.objects.get_or_create(
|
|
complaint=complaint,
|
|
staff=staff_member,
|
|
defaults={"added_by": user},
|
|
)
|
|
|
|
# Set forwarded timestamp and update tracking fields if active
|
|
now = timezone.now()
|
|
if complaint.is_active_status and complaint.status in ("open", "in_progress"):
|
|
if not complaint.forwarded_to_dept_at:
|
|
complaint.forwarded_to_dept_at = now
|
|
complaint.save(update_fields=[
|
|
"forwarded_to_dept_at",
|
|
"sent_to_department", "sent_to_department_at",
|
|
"explanation_requested", "explanation_requested_at",
|
|
])
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="status_change",
|
|
message=f"Complaint sent to {recipient_type}",
|
|
created_by=user,
|
|
)
|
|
else:
|
|
if not complaint.forwarded_to_dept_at:
|
|
complaint.forwarded_to_dept_at = now
|
|
complaint.save(update_fields=[
|
|
"forwarded_to_dept_at",
|
|
"sent_to_department", "sent_to_department_at",
|
|
"explanation_requested", "explanation_requested_at",
|
|
])
|
|
|
|
# Offload the champion/manager email + SMS to a Celery task (slow network
|
|
# round-trips) so the HTTP response returns immediately. Best-effort.
|
|
if notify_targets:
|
|
try:
|
|
from .tasks import send_department_notification_task
|
|
send_department_notification_task.delay({
|
|
"complaint_id": str(complaint.id),
|
|
"reference_number": complaint.reference_number,
|
|
"title": complaint.title,
|
|
"department_name": department.name,
|
|
"note": note,
|
|
"email_subject": email_subject,
|
|
"email_body": email_body,
|
|
"targets": notify_targets,
|
|
})
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to dispatch department notification task for "
|
|
f"complaint #{complaint.reference_number}: {e}"
|
|
)
|
|
|
|
return JsonResponse({
|
|
"success": True,
|
|
"message": message,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in complaint_send_to: {str(e)}")
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("An error occurred while sending the complaint.")),
|
|
}, status=500)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_send_dept_reminder(request, pk):
|
|
"""Send a manual reminder to a department that hasn't responded yet.
|
|
|
|
Visible in Quick Actions after the complaint has been sent to a department.
|
|
The user picks which department from a modal; this endpoint sends an email +
|
|
SMS to that department's champion/manager and logs a ComplaintUpdate.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment, ComplaintUpdate
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
from apps.organizations.department_contacts import get_champion_and_manager
|
|
from apps.core.utils import build_public_track_url
|
|
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
user = request.user
|
|
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to send reminders."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
inv_dept_id = request.POST.get("involved_department_id", "").strip()
|
|
note = request.POST.get("note", "").strip()
|
|
|
|
if not inv_dept_id:
|
|
messages.error(request, _("Please select a department."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
involved_dept = get_object_or_404(
|
|
ComplaintInvolvedDepartment,
|
|
pk=inv_dept_id,
|
|
complaint=complaint,
|
|
sent=True,
|
|
response_submitted=False,
|
|
)
|
|
department = involved_dept.department
|
|
|
|
reminder_type = request.POST.get("reminder_type", "first")
|
|
|
|
if reminder_type == "first" and involved_dept.first_reminder_sent_at:
|
|
messages.warning(request, _("First reminder already sent to this department."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
if reminder_type == "second" and involved_dept.second_reminder_sent_at:
|
|
messages.warning(request, _("Second reminder already sent to this department."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
if reminder_type == "second" and not involved_dept.first_reminder_sent_at:
|
|
messages.warning(request, _("Please send the first reminder before the second."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
targets = get_champion_and_manager(department)
|
|
if not targets:
|
|
messages.error(
|
|
request,
|
|
_(f"Cannot send reminder to {department.get_localized_name()}. This department has no champion or manager assigned."),
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
track_url = build_public_track_url("complaint", complaint.reference_number)
|
|
notified = []
|
|
sent_any = False
|
|
|
|
for target in targets:
|
|
email = target.get("email") or ""
|
|
phone = target.get("phone") or ""
|
|
label = target.get("label") or ""
|
|
display_name = target.get("display_name") or label
|
|
|
|
if email:
|
|
try:
|
|
NotificationService.send_email(
|
|
email=email,
|
|
subject=f"Reminder: Complaint #{complaint.reference_number} - Response Required",
|
|
message=(
|
|
f"This is a reminder that complaint #{complaint.reference_number} "
|
|
f"is awaiting your department's response.\n\n"
|
|
f"Track: {track_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;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Reminder: Response Required</h2>
|
|
<p style="margin: 0 0 12px 0;">This is a reminder that complaint <strong>#{complaint.reference_number}</strong> is still awaiting your department's response.</p>
|
|
<p style="margin: 0 0 12px 0;"><strong>Department:</strong> {department.name}</p>
|
|
<div style="text-align: center; margin: 20px 0;">
|
|
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Track Complaint</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=complaint,
|
|
)
|
|
sent_any = True
|
|
except Exception:
|
|
pass
|
|
if phone:
|
|
try:
|
|
NotificationService.send_sms(
|
|
phone,
|
|
f"PX360: Reminder - Complaint #{complaint.reference_number} sent to {department.name} is awaiting your response. Track: {track_url}",
|
|
related_object=complaint,
|
|
)
|
|
sent_any = True
|
|
except Exception:
|
|
pass
|
|
|
|
notified.append(f"{display_name} ({label})")
|
|
|
|
if sent_any:
|
|
if reminder_type == "first":
|
|
involved_dept.first_reminder_sent_at = timezone.now()
|
|
else:
|
|
involved_dept.second_reminder_sent_at = timezone.now()
|
|
involved_dept.save(update_fields=["first_reminder_sent_at", "second_reminder_sent_at"])
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="communication",
|
|
message=f"Manual {reminder_type} reminder sent to {department.get_localized_name()} — {', '.join(notified)}",
|
|
created_by=user,
|
|
metadata={
|
|
"event_type": "manual_reminder",
|
|
"department_id": str(department.id),
|
|
"involved_department_id": str(involved_dept.id),
|
|
"recipients": notified,
|
|
"note": note,
|
|
},
|
|
)
|
|
messages.success(request, _(f"Reminder sent to {department.get_localized_name()}."))
|
|
else:
|
|
messages.error(request, _(f"Could not send reminder to {department.get_localized_name()}. No valid contact info."))
|
|
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_change_status(request, pk):
|
|
"""Change complaint status"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
new_status = request.POST.get("status")
|
|
note = request.POST.get("note", "")
|
|
resolution = request.POST.get("resolution", "")
|
|
resolution_outcome = request.POST.get("resolution_outcome", "")
|
|
resolution_outcome_other = request.POST.get("resolution_outcome_other", "")
|
|
|
|
try:
|
|
ComplaintService.change_status(
|
|
complaint,
|
|
new_status,
|
|
request.user,
|
|
request=request,
|
|
note=note,
|
|
resolution=resolution,
|
|
resolution_outcome=resolution_outcome,
|
|
resolution_outcome_other=resolution_outcome_other,
|
|
)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_complaint_status_changed(
|
|
complaint.assigned_to.email if complaint.assigned_to and complaint.assigned_to.email else None,
|
|
complaint,
|
|
complaint.status,
|
|
new_status,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
messages.success(request, f"Complaint status changed to {new_status}.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def update_satisfaction(request, pk):
|
|
"""Update complaint satisfaction rating (resolved/closed complaints only)."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if complaint.status not in ("resolved", "closed"):
|
|
messages.error(request, _("Satisfaction can only be set for resolved or closed complaints."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, _("You don't have permission to update satisfaction."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.satisfaction_locked_by_patient:
|
|
messages.error(
|
|
request,
|
|
_("Satisfaction is locked because the patient has submitted their feedback and cannot be changed."),
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.satisfaction_window_expired:
|
|
messages.error(
|
|
request,
|
|
_("Satisfaction can no longer be set. The 5-day window after resolution has expired."),
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
satisfaction = request.POST.get("satisfaction", "")
|
|
valid_choices = ["satisfied", "neutral", "dissatisfied", "no_response"]
|
|
if satisfaction and satisfaction not in valid_choices:
|
|
messages.error(request, _("Invalid satisfaction value."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
old_satisfaction = complaint.satisfaction
|
|
|
|
# Only real satisfaction ratings (satisfied/neutral/dissatisfied) consume
|
|
# the change budget. "no_response" does NOT count.
|
|
counts_as_change = satisfaction in ("satisfied", "neutral", "dissatisfied")
|
|
is_new_rating = satisfaction != old_satisfaction
|
|
|
|
if counts_as_change and is_new_rating and complaint.satisfaction_change_count >= Complaint.MAX_SATISFACTION_CHANGES:
|
|
messages.error(
|
|
request,
|
|
_("Satisfaction has been changed {} times and can no longer be modified by PX-team. "
|
|
"Only the patient can override it via the tracking page.").format(Complaint.MAX_SATISFACTION_CHANGES),
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
from django.utils import timezone
|
|
|
|
complaint.satisfaction = satisfaction
|
|
if satisfaction:
|
|
complaint.satisfaction_set_at = timezone.now()
|
|
complaint.satisfaction_set_by = request.user
|
|
else:
|
|
complaint.satisfaction_set_at = None
|
|
complaint.satisfaction_set_by = None
|
|
|
|
# Increment counter only for real ratings that represent a change
|
|
if counts_as_change and is_new_rating:
|
|
complaint.satisfaction_change_count += 1
|
|
|
|
complaint.save(update_fields=[
|
|
"satisfaction", "satisfaction_set_at", "satisfaction_set_by",
|
|
"satisfaction_change_count", "updated_at",
|
|
])
|
|
|
|
# Log with transition (from → to)
|
|
SATISFACTION_LABELS = {
|
|
"satisfied": _("Satisfied"), "neutral": _("Neutral"),
|
|
"dissatisfied": _("Dissatisfied"), "no_response": _("No Response"),
|
|
}
|
|
if is_new_rating and satisfaction:
|
|
new_label = SATISFACTION_LABELS.get(satisfaction, satisfaction)
|
|
if old_satisfaction:
|
|
old_label = str(SATISFACTION_LABELS.get(old_satisfaction, old_satisfaction))
|
|
log_msg = f"Satisfaction changed from '{old_label}' to '{new_label}' by {request.user.get_full_name()}"
|
|
else:
|
|
log_msg = f"Satisfaction set to '{new_label}' by {request.user.get_full_name()}"
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=log_msg,
|
|
created_by=request.user,
|
|
)
|
|
elif not satisfaction and old_satisfaction:
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Satisfaction cleared by {request.user.get_full_name()}",
|
|
created_by=request.user,
|
|
)
|
|
|
|
if satisfaction:
|
|
messages.success(request, _("Satisfaction updated to: {}").format(complaint.get_satisfaction_display()))
|
|
else:
|
|
messages.success(request, _("Satisfaction cleared."))
|
|
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def update_patient_contact_status(request, pk):
|
|
"""Update patient contact status (contacted or contacted no response)."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, _("You don't have permission to update patient contact status."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
patient_contact_status = request.POST.get("patient_contact_status", "")
|
|
valid_choices = ["not_contacted", "contacted", "contacted_no_response"]
|
|
if patient_contact_status and patient_contact_status not in valid_choices:
|
|
messages.error(request, _("Invalid patient contact status."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
from django.utils import timezone
|
|
|
|
old_status = complaint.patient_contact_status
|
|
complaint.patient_contact_status = patient_contact_status
|
|
if patient_contact_status and patient_contact_status != "not_contacted":
|
|
complaint.patient_contact_status_at = timezone.now()
|
|
complaint.patient_contact_status_by = request.user
|
|
else:
|
|
complaint.patient_contact_status_at = None
|
|
complaint.patient_contact_status_by = None
|
|
complaint.save(update_fields=["patient_contact_status", "patient_contact_status_at", "patient_contact_status_by", "updated_at"])
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="communication",
|
|
message=f"Patient contact status changed from {old_status} to {patient_contact_status}",
|
|
created_by=request.user,
|
|
)
|
|
|
|
messages.success(request, _("Patient contact status updated to: {}").format(complaint.get_patient_contact_status_display()))
|
|
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def toggle_escalated_ovr(request, pk):
|
|
"""Request OVR escalation or cancel pending request."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, _("You don't have permission to update this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.status == ComplaintStatus.OVR_PENDING:
|
|
complaint.status = ComplaintStatus.IN_PROGRESS
|
|
complaint.is_escalated_ovr = False
|
|
complaint.escalated_ovr_by = None
|
|
complaint.escalated_ovr_at = None
|
|
complaint.metadata.pop("ovr_requested_by", None)
|
|
complaint.metadata.pop("ovr_requested_at", None)
|
|
messages.success(request, _("OVR escalation request cancelled."))
|
|
else:
|
|
complaint.status = ComplaintStatus.OVR_PENDING
|
|
complaint.is_escalated_ovr = False
|
|
complaint.metadata["ovr_requested_by"] = str(request.user.pk)
|
|
complaint.metadata["ovr_requested_at"] = timezone.now().isoformat()
|
|
messages.success(request, _("OVR escalation requested. Waiting for admin approval."))
|
|
|
|
# Send email notification to PX admins and management
|
|
_send_ovr_request_notification(complaint, request.user)
|
|
|
|
complaint.save(update_fields=["status", "is_escalated_ovr", "escalated_ovr_by", "escalated_ovr_at", "metadata", "updated_at"])
|
|
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
def _send_ovr_request_notification(complaint, requested_by):
|
|
"""Send email notification to admins when OVR escalation is requested"""
|
|
from django.conf import settings
|
|
from apps.accounts.models import User
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
|
|
try:
|
|
admin_users = User.objects.filter(
|
|
is_active=True,
|
|
groups__name__in=["PX Admin", "Hospital Admin", "PX Management"],
|
|
hospital=complaint.hospital
|
|
).distinct()
|
|
|
|
subject = f"OVR Escalation Request - Complaint #{complaint.reference_number}"
|
|
|
|
for admin in admin_users:
|
|
if admin.email:
|
|
message = f"""A new OVR escalation request requires your approval.
|
|
|
|
Complaint: #{complaint.reference_number}
|
|
Title: {complaint.title}
|
|
Requested by: {requested_by.get_full_name()}
|
|
Hospital: {complaint.hospital.name}
|
|
|
|
Please review and approve/reject this request in the complaints system.
|
|
|
|
URL: {settings.SITE_URL.rstrip('/')}/complaints/{complaint.pk}/
|
|
"""
|
|
NotificationService.send_email(
|
|
email=admin.email,
|
|
subject=subject,
|
|
message=message,
|
|
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;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">OVR Escalation Request</h2>
|
|
<p style="margin: 0 0 12px 0;">A new OVR escalation request requires your approval.</p>
|
|
<table style="width: 100%; border-collapse: collapse; margin: 0 0 12px 0;">
|
|
<tr><td style="padding: 4px 0; color: #6b7280; width: 140px;">Complaint:</td><td style="padding: 4px 0;"><strong>#{complaint.reference_number}</strong></td></tr>
|
|
<tr><td style="padding: 4px 0; color: #6b7280;">Title:</td><td style="padding: 4px 0;">{complaint.title}</td></tr>
|
|
<tr><td style="padding: 4px 0; color: #6b7280;">Requested by:</td><td style="padding: 4px 0;">{requested_by.get_full_name()}</td></tr>
|
|
<tr><td style="padding: 4px 0; color: #6b7280;">Hospital:</td><td style="padding: 4px 0;">{complaint.hospital.name}</td></tr>
|
|
</table>
|
|
<div style="text-align: center; margin: 20px 0;">
|
|
<a href="{settings.SITE_URL.rstrip('/')}/complaints/{complaint.pk}/" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Review Request</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
""",
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f"Failed to send OVR notification: {e}")
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def approve_ovr_escalation(request, pk):
|
|
"""Approve OVR escalation request."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not request.user.is_px_admin() and not request.user.is_hospital_admin():
|
|
messages.error(request, _("You don't have permission to approve OVR escalation."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.status != ComplaintStatus.OVR_PENDING:
|
|
messages.error(request, _("This complaint does not have a pending OVR request."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
complaint.status = ComplaintStatus.IN_PROGRESS
|
|
complaint.is_escalated_ovr = True
|
|
complaint.escalated_ovr_by = request.user
|
|
complaint.escalated_ovr_at = timezone.now()
|
|
requested_by = complaint.metadata.get("ovr_requested_by", "")
|
|
complaint.metadata.pop("ovr_requested_by", None)
|
|
complaint.metadata.pop("ovr_requested_at", None)
|
|
complaint.metadata["ovr_approved_by"] = str(request.user.pk)
|
|
complaint.metadata["ovr_approved_at"] = timezone.now().isoformat()
|
|
complaint.save(update_fields=["status", "is_escalated_ovr", "escalated_ovr_by", "escalated_ovr_at", "metadata", "updated_at"])
|
|
|
|
messages.success(request, _("OVR escalation approved."))
|
|
_send_ovr_decision_notification(complaint, request.user, "approved")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def reject_ovr_escalation(request, pk):
|
|
"""Reject OVR escalation request."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not request.user.is_px_admin() and not request.user.is_hospital_admin():
|
|
messages.error(request, _("You don't have permission to reject OVR escalation."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.status != ComplaintStatus.OVR_PENDING:
|
|
messages.error(request, _("This complaint does not have a pending OVR request."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
complaint.status = ComplaintStatus.IN_PROGRESS
|
|
complaint.metadata.pop("ovr_requested_by", None)
|
|
complaint.metadata.pop("ovr_requested_at", None)
|
|
complaint.metadata["ovr_rejected_by"] = str(request.user.pk)
|
|
complaint.metadata["ovr_rejected_at"] = timezone.now().isoformat()
|
|
complaint.save(update_fields=["status", "metadata", "updated_at"])
|
|
|
|
messages.success(request, _("OVR escalation request rejected."))
|
|
_send_ovr_decision_notification(complaint, request.user, "rejected")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
def _send_ovr_decision_notification(complaint, decided_by, decision):
|
|
"""Send email notification to requester when OVR is approved or rejected"""
|
|
from django.conf import settings
|
|
from apps.accounts.models import User
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
|
|
try:
|
|
requested_by_pk = complaint.metadata.get("ovr_requested_by")
|
|
if not requested_by_pk:
|
|
return
|
|
|
|
requested_by_user = User.objects.filter(pk=requested_by_pk).first()
|
|
if not requested_by_user or not requested_by_user.email:
|
|
return
|
|
|
|
decision_text = "approved" if decision == "approved" else "rejected"
|
|
subject = f"OVR Escalation {decision_text.title()} - Complaint #{complaint.reference_number}"
|
|
|
|
message = f"""Your OVR escalation request for complaint #{complaint.reference_number} has been {decision_text}.
|
|
|
|
Complaint: #{complaint.reference_number}
|
|
Title: {complaint.title}
|
|
Decision: {decision_text.title()} by {decided_by.get_full_name()}
|
|
|
|
{"The complaint is now escalated as OVR." if decision == "approved" else "Please contact the admin for more information."}
|
|
|
|
URL: {settings.SITE_URL.rstrip('/')}/complaints/{complaint.pk}/
|
|
"""
|
|
NotificationService.send_email(
|
|
email=requested_by_user.email,
|
|
subject=subject,
|
|
message=message,
|
|
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;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">OVR Escalation {decision_text.title()}</h2>
|
|
<p style="margin: 0 0 12px 0;">Your OVR escalation request for complaint <strong>#{complaint.reference_number}</strong> has been <strong>{decision_text}</strong>.</p>
|
|
<table style="width: 100%; border-collapse: collapse; margin: 0 0 12px 0;">
|
|
<tr><td style="padding: 4px 0; color: #6b7280; width: 120px;">Title:</td><td style="padding: 4px 0;">{complaint.title}</td></tr>
|
|
<tr><td style="padding: 4px 0; color: #6b7280;">Decision:</td><td style="padding: 4px 0;">{decision_text.title()} by {decided_by.get_full_name()}</td></tr>
|
|
</table>
|
|
<p style="margin: 0 0 12px 0;">{"The complaint is now escalated as OVR." if decision == "approved" else "Please contact the admin for more information."}</p>
|
|
<div style="text-align: center; margin: 20px 0;">
|
|
<a href="{settings.SITE_URL.rstrip('/')}/complaints/{complaint.pk}/" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Complaint</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
""",
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f"Failed to send OVR decision notification: {e}")
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def reject_ovr_escalation(request, pk):
|
|
"""Reject OVR escalation request."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not request.user.is_px_admin() and not request.user.is_hospital_admin():
|
|
messages.error(request, _("You don't have permission to reject OVR escalation."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.status != ComplaintStatus.OVR_PENDING:
|
|
messages.error(request, _("This complaint does not have a pending OVR request."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
complaint.status = ComplaintStatus.IN_PROGRESS
|
|
complaint.metadata.pop("ovr_requested_by", None)
|
|
complaint.metadata.pop("ovr_requested_at", None)
|
|
complaint.metadata["ovr_rejected_by"] = str(request.user.pk)
|
|
complaint.metadata["ovr_rejected_at"] = timezone.now().isoformat()
|
|
complaint.save(update_fields=["status", "metadata", "updated_at"])
|
|
|
|
messages.success(request, _("OVR escalation request rejected."))
|
|
_send_ovr_decision_notification(complaint, request.user, "rejected")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_reopen(request, pk):
|
|
"""Reopen a resolved or closed complaint"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or complaint.assigned_to == user
|
|
or (user.is_department_manager() and user.department and user.department == complaint.department)
|
|
):
|
|
messages.error(request, "You don't have permission to reopen this complaint.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
note = request.POST.get("note", "")
|
|
|
|
if complaint.status not in ("resolved", "closed"):
|
|
messages.error(request, "Only resolved or closed complaints can be reopened.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
ComplaintService.reopen(complaint, request.user, request=request, note=note)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_complaint_status_changed(
|
|
complaint.assigned_to.email if complaint.assigned_to and complaint.assigned_to.email else None,
|
|
complaint,
|
|
complaint.status,
|
|
"in_progress",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
messages.success(request, "Complaint reopened successfully.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def update_explanation_delay_reason(request, pk):
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, "You don't have permission to update this complaint.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
reason = request.POST.get("explanation_delay_reason", "")
|
|
old_reason = complaint.explanation_delay_reason
|
|
complaint.explanation_delay_reason = reason
|
|
complaint.save(update_fields=["explanation_delay_reason", "updated_at"])
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Explanation delay reason updated: {reason}" if reason else "Explanation delay reason cleared",
|
|
created_by=request.user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="update",
|
|
description="Explanation delay reason updated",
|
|
user=request.user,
|
|
content_object=complaint,
|
|
metadata={"old_value": old_reason, "new_value": reason},
|
|
)
|
|
|
|
messages.success(request, "Explanation delay reason updated.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def update_delay_reason_closure(request, pk):
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, "You don't have permission to update this complaint.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.status in ("closed", "resolved"):
|
|
messages.error(request, "Cannot update delay reason for closed/resolved complaints.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
from django.utils import timezone
|
|
|
|
hours_since_creation = (timezone.now() - complaint.created_at).total_seconds() / 3600
|
|
if not complaint.is_overdue and hours_since_creation < 72:
|
|
messages.error(request, "Delay reason can only be set when complaint is overdue or past 72 hours.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
reason = request.POST.get("delay_reason_closure", "")
|
|
from .models import DelayReasonChoices
|
|
valid_reasons = [choice[0] for choice in DelayReasonChoices.choices]
|
|
if reason and reason not in valid_reasons:
|
|
messages.error(request, "Invalid delay reason.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
old_reason = complaint.delay_reason_closure
|
|
complaint.delay_reason_closure = reason
|
|
complaint.save(update_fields=["delay_reason_closure", "updated_at"])
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Closure delay reason updated: {reason}" if reason else "Closure delay reason cleared",
|
|
created_by=request.user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="update",
|
|
description="Closure delay reason updated",
|
|
user=request.user,
|
|
content_object=complaint,
|
|
metadata={"old_value": old_reason, "new_value": reason},
|
|
)
|
|
|
|
messages.success(request, "Closure delay reason updated.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_add_note(request, pk):
|
|
"""Add note to complaint"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
note = request.POST.get("note")
|
|
|
|
try:
|
|
ComplaintService.add_note(complaint, note, request.user, request=request)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
messages.success(request, "Note added successfully.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_change_department(request, pk):
|
|
"""Change complaint department"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
department_id = request.POST.get("department_id")
|
|
if not department_id:
|
|
messages.error(request, "Please select a department.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
department = Department.objects.get(id=department_id)
|
|
ComplaintService.change_department(complaint, department, request.user, request=request)
|
|
except Department.DoesNotExist:
|
|
messages.error(request, "Department not found.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
messages.success(request, f"Department changed to {department.name}.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_update_location(request, pk):
|
|
"""Update complaint location details (location_type, area, department, section)."""
|
|
from apps.organizations.models import Area, Section
|
|
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, _("You don't have permission to update this complaint's location."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
location_type = (request.POST.get("location_type") or "").strip()
|
|
area_id = (request.POST.get("area") or "").strip()
|
|
department_id = (request.POST.get("department") or "").strip()
|
|
section_id = (request.POST.get("section") or "").strip()
|
|
zone = request.POST.get("zone")
|
|
if zone is not None:
|
|
zone = zone.strip()
|
|
floor = request.POST.get("floor")
|
|
if floor is not None:
|
|
floor = floor.strip()
|
|
|
|
area = Area.objects.filter(id=area_id).first() if area_id else None
|
|
department = Department.objects.filter(id=department_id).first() if department_id else None
|
|
section = Section.objects.filter(id=section_id).first() if section_id else None
|
|
|
|
try:
|
|
ComplaintService.update_location(
|
|
complaint,
|
|
location_type=location_type,
|
|
area=area,
|
|
department=department,
|
|
section=section,
|
|
changed_by=request.user,
|
|
request=request,
|
|
zone=zone,
|
|
floor=floor,
|
|
)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
messages.success(request, _("Location details updated."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_escalate(request, pk):
|
|
"""Escalate complaint to selected staff (default is staff's manager) - Admin and Dept Manager only"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
# Check if complaint is in active status
|
|
if not complaint.is_active_status:
|
|
messages.error(
|
|
request,
|
|
f"Cannot escalate complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.",
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
# Complaint must be activated before it can be escalated
|
|
if not complaint.activated_at:
|
|
messages.error(request, "Activate the complaint before escalating it.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
messages.error(request, "You don't have permission to escalate complaints.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
reason = request.POST.get("reason", "")
|
|
escalate_to_id = request.POST.get("escalate_to", "")
|
|
|
|
escalate_to_staff = None
|
|
escalate_to_user = None
|
|
escalate_to_email = None
|
|
|
|
if escalate_to_id:
|
|
try:
|
|
escalate_to_staff = Staff.objects.get(id=escalate_to_id)
|
|
if escalate_to_staff.user and escalate_to_staff.user.is_active:
|
|
escalate_to_user = escalate_to_staff.user
|
|
escalate_to_email = escalate_to_staff.user.email
|
|
elif escalate_to_staff.email:
|
|
escalate_to_email = escalate_to_staff.email
|
|
except Staff.DoesNotExist:
|
|
pass
|
|
|
|
if not escalate_to_staff:
|
|
messages.error(request, _("Please select a valid person to escalate to."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
escalate_to_name = escalate_to_staff.get_full_name()
|
|
|
|
# Mark as escalated — do NOT reassign. The complaint stays with the
|
|
# PX-team user who activated it. The escalated person is notified via email.
|
|
complaint.escalated_at = timezone.now()
|
|
complaint.save(update_fields=["escalated_at"])
|
|
|
|
# Create update with escalation details
|
|
escalation_message = f"Complaint escalated. Reason: {reason}"
|
|
escalation_message += f" Escalated to: {escalate_to_name}"
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="escalation",
|
|
message=escalation_message,
|
|
created_by=request.user,
|
|
metadata={
|
|
"reason": reason,
|
|
"escalated_to_staff_id": str(escalate_to_staff.id),
|
|
"escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None,
|
|
"escalated_to_user_name": escalate_to_name,
|
|
},
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="escalation",
|
|
description=f"Complaint escalated to {escalate_to_name}",
|
|
user=request.user,
|
|
content_object=complaint,
|
|
metadata={
|
|
"reason": reason,
|
|
"escalated_to_staff_id": str(escalate_to_staff.id),
|
|
"escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None,
|
|
"escalated_to_user_name": escalate_to_name,
|
|
},
|
|
)
|
|
|
|
# Send notification email
|
|
if escalate_to_email:
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
|
|
email_subject = request.POST.get("email_subject", f"Complaint Escalated - {complaint.reference_number}")
|
|
email_body = request.POST.get("email_body", "")
|
|
|
|
complaint_url = f"https://{request.get_host()}/organizations/departments/{complaint.department.pk}/complaints/{complaint.pk}/" if complaint.department else ""
|
|
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;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Complaint Escalated</h2>
|
|
<p>Dear Manager,</p>
|
|
<p>This complaint has been escalated and requires your immediate attention.</p>
|
|
<p><strong>Reference:</strong> {complaint.reference_number}<br>
|
|
<strong>Title:</strong> {complaint.title or 'N/A'}<br>
|
|
<strong>Severity:</strong> {complaint.get_severity_display()}<br>
|
|
<strong>Priority:</strong> {complaint.get_priority_display()}<br>
|
|
<strong>Status:</strong> {complaint.get_status_display()}</p>
|
|
<p>Please review and take appropriate action.</p>
|
|
<p style="margin-top: 16px;">
|
|
<a href="{complaint_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Complaint</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
try:
|
|
NotificationService.send_email(
|
|
email=escalate_to_email,
|
|
subject=email_subject,
|
|
message=email_body,
|
|
html_message=html_message,
|
|
related_object=complaint,
|
|
metadata={
|
|
"notification_type": "complaint_escalated",
|
|
"escalated_by": str(request.user.id),
|
|
"reason": reason,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send escalation notification: {e}")
|
|
|
|
messages.success(
|
|
request,
|
|
f"Complaint escalated successfully to {escalate_to_name}.",
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_activate(request, pk):
|
|
"""Activate complaint and assign to current user."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
try:
|
|
ComplaintService.activate(complaint, request.user, request=request)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
messages.success(request, "Complaint activated and assigned to you successfully.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def confirm_taxonomy(request, pk):
|
|
"""Confirm that the taxonomy classification has been reviewed.
|
|
|
|
Sets taxonomy_reviewed_at/by so the workflow stepper advances past
|
|
the 'Review Taxonomy' step, unblocking 'Send to Department'.
|
|
The taxonomy itself remains editable afterwards via the Edit pencil.
|
|
"""
|
|
from django.utils import timezone
|
|
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, _("You don't have permission to confirm taxonomy for this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
complaint.taxonomy_reviewed_at = timezone.now()
|
|
complaint.taxonomy_reviewed_by = request.user
|
|
complaint.save(update_fields=["taxonomy_reviewed_at", "taxonomy_reviewed_by", "updated_at"])
|
|
|
|
messages.success(request, _("Taxonomy confirmed. You can now send to department."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
def complaint_export_csv(request):
|
|
"""Export complaints to CSV"""
|
|
from apps.complaints.utils import export_complaints_csv
|
|
|
|
# Get filtered queryset (reuse list view logic)
|
|
queryset = Complaint.objects.select_related(
|
|
"patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by"
|
|
)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if user.is_px_admin():
|
|
pass
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
elif user.is_department_manager() and user.department:
|
|
queryset = queryset.filter(department=user.department)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
# Apply filters from request
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
severity_filter = request.GET.get("severity")
|
|
if severity_filter:
|
|
queryset = queryset.filter(severity=severity_filter)
|
|
|
|
priority_filter = request.GET.get("priority")
|
|
if priority_filter:
|
|
queryset = queryset.filter(priority=priority_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(department_id=department_filter)
|
|
|
|
overdue_filter = request.GET.get("is_overdue")
|
|
if overdue_filter == "true":
|
|
queryset = queryset.filter(is_overdue=True)
|
|
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(patient__mrn__icontains=search_query)
|
|
)
|
|
|
|
return export_complaints_csv(queryset, request.GET.dict())
|
|
|
|
|
|
@login_required
|
|
def complaint_export_excel(request):
|
|
"""Export complaints to Excel"""
|
|
from apps.complaints.utils import export_complaints_excel
|
|
|
|
# Get filtered queryset (same as CSV)
|
|
queryset = Complaint.objects.select_related(
|
|
"patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by"
|
|
)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if user.is_px_admin():
|
|
pass
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
elif user.is_department_manager() and user.department:
|
|
queryset = queryset.filter(department=user.department)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
# Apply filters from request
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
severity_filter = request.GET.get("severity")
|
|
if severity_filter:
|
|
queryset = queryset.filter(severity=severity_filter)
|
|
|
|
priority_filter = request.GET.get("priority")
|
|
if priority_filter:
|
|
queryset = queryset.filter(priority=priority_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(department_id=department_filter)
|
|
|
|
overdue_filter = request.GET.get("is_overdue")
|
|
if overdue_filter == "true":
|
|
queryset = queryset.filter(is_overdue=True)
|
|
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(patient__mrn__icontains=search_query)
|
|
)
|
|
|
|
return export_complaints_excel(queryset, request.GET.dict())
|
|
|
|
|
|
@login_required
|
|
def complaint_export_historical_excel(request):
|
|
"""Export complaints to historical Excel format with Arabic headers."""
|
|
from apps.complaints.utils import export_historical_excel
|
|
|
|
# Get filtered queryset
|
|
queryset = Complaint.objects.select_related(
|
|
"patient",
|
|
"hospital",
|
|
"department",
|
|
"staff",
|
|
"assigned_to",
|
|
"resolved_by",
|
|
"closed_by",
|
|
"created_by",
|
|
"source",
|
|
"domain",
|
|
"category",
|
|
"subcategory_obj",
|
|
"classification_obj",
|
|
)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if user.is_px_admin():
|
|
pass
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
elif user.is_department_manager() and user.department:
|
|
queryset = queryset.filter(department=user.department)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
# Apply date range filter
|
|
date_start = request.GET.get("date_start")
|
|
date_end = request.GET.get("date_end")
|
|
|
|
if date_start:
|
|
from datetime import datetime
|
|
|
|
try:
|
|
date_start_dt = datetime.strptime(date_start, "%Y-%m-%d")
|
|
queryset = queryset.filter(created_at__date__gte=date_start_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
if date_end:
|
|
from datetime import datetime
|
|
|
|
try:
|
|
date_end_dt = datetime.strptime(date_end, "%Y-%m-%d")
|
|
queryset = queryset.filter(created_at__date__lte=date_end_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Apply other filters from request
|
|
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(department_id=department_filter)
|
|
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(patient__mrn__icontains=search_query)
|
|
)
|
|
|
|
return export_historical_excel(queryset, date_start, date_end)
|
|
|
|
|
|
@login_required
|
|
def complaint_export_monthly_calculations(request):
|
|
"""
|
|
Step 1 — Export monthly calculations report to Excel.
|
|
"""
|
|
from apps.complaints.utils import export_monthly_calculations
|
|
from django.core.exceptions import PermissionDenied
|
|
|
|
if not (
|
|
request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
or request.user.is_px_management() or request.user.is_px_employee()
|
|
):
|
|
raise PermissionDenied("Only PX Admins and Hospital Admins can export.")
|
|
|
|
year = request.GET.get("year")
|
|
month = request.GET.get("month")
|
|
if not year or not month:
|
|
from django.contrib import messages
|
|
|
|
messages.error(request, "Year and month are required for monthly calculations export.")
|
|
return redirect("complaints:complaint_list")
|
|
|
|
queryset = Complaint.objects.filter(
|
|
created_at__year=int(year),
|
|
created_at__month=int(month),
|
|
)
|
|
|
|
if request.user.is_hospital_admin() and request.user.hospital:
|
|
queryset = queryset.filter(hospital=request.user.hospital)
|
|
elif request.user.is_px_admin() and request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
queryset = queryset.select_related(
|
|
"hospital",
|
|
"department",
|
|
"assigned_to",
|
|
"resolved_by",
|
|
"closed_by",
|
|
"created_by",
|
|
"source",
|
|
).prefetch_related("involved_departments__department")
|
|
|
|
return export_monthly_calculations(queryset, int(year), int(month))
|
|
|
|
|
|
@login_required
|
|
def complaint_export_quarterly_calculations(request):
|
|
"""
|
|
Step 2 — Export quarterly calculations report to Excel.
|
|
"""
|
|
from apps.complaints.utils import export_quarterly_calculations
|
|
from django.core.exceptions import PermissionDenied
|
|
|
|
if not (
|
|
request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
or request.user.is_px_management() or request.user.is_px_employee()
|
|
):
|
|
raise PermissionDenied("Only PX Admins and Hospital Admins can export.")
|
|
|
|
year = request.GET.get("year")
|
|
quarter = request.GET.get("quarter")
|
|
if not year or not quarter:
|
|
from django.contrib import messages
|
|
|
|
messages.error(request, "Year and quarter are required.")
|
|
return redirect("complaints:complaint_list")
|
|
|
|
year, quarter = int(year), int(quarter)
|
|
quarter_months = {1: (1, 3), 2: (4, 6), 3: (7, 9), 4: (10, 12)}
|
|
start_month, end_month = quarter_months[quarter]
|
|
|
|
queryset = Complaint.objects.filter(
|
|
created_at__year=year,
|
|
created_at__month__gte=start_month,
|
|
created_at__month__lte=end_month,
|
|
)
|
|
|
|
if request.user.is_hospital_admin() and request.user.hospital:
|
|
queryset = queryset.filter(hospital=request.user.hospital)
|
|
elif request.user.is_px_admin() and request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
return export_quarterly_calculations(queryset, year, quarter)
|
|
|
|
|
|
@login_required
|
|
def complaint_export_yearly_calculations(request):
|
|
"""
|
|
Export yearly calculations report to Excel.
|
|
"""
|
|
from apps.complaints.utils import export_yearly_calculations
|
|
from django.core.exceptions import PermissionDenied
|
|
|
|
if not (
|
|
request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
or request.user.is_px_management() or request.user.is_px_employee()
|
|
):
|
|
raise PermissionDenied("Only PX Admins and Hospital Admins can export.")
|
|
|
|
year = request.GET.get("year")
|
|
if not year:
|
|
from django.contrib import messages
|
|
|
|
messages.error(request, "Year is required.")
|
|
return redirect("complaints:complaint_list")
|
|
|
|
year = int(year)
|
|
queryset = Complaint.objects.filter(created_at__year=year)
|
|
|
|
if request.user.is_hospital_admin() and request.user.hospital:
|
|
queryset = queryset.filter(hospital=request.user.hospital)
|
|
elif request.user.is_px_admin() and request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
return export_yearly_calculations(queryset, year)
|
|
|
|
|
|
@hospital_admin_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_bulk_assign(request):
|
|
"""Bulk assign complaints - Admin only"""
|
|
from apps.complaints.utils import bulk_assign_complaints
|
|
import json
|
|
|
|
try:
|
|
data = json.loads(request.body)
|
|
complaint_ids = data.get("complaint_ids", [])
|
|
user_id = data.get("user_id")
|
|
|
|
if not complaint_ids or not user_id:
|
|
return JsonResponse({"success": False, "error": "Missing required fields"}, status=400)
|
|
|
|
result = bulk_assign_complaints(complaint_ids, user_id, request.user)
|
|
|
|
if result["success"]:
|
|
messages.success(request, f"Successfully assigned {result['success_count']} complaints.")
|
|
|
|
return JsonResponse(result)
|
|
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400)
|
|
except Exception as e:
|
|
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
|
|
|
|
|
@hospital_admin_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_bulk_status(request):
|
|
"""Bulk change complaint status - Admin only"""
|
|
from apps.complaints.utils import bulk_change_status
|
|
import json
|
|
|
|
try:
|
|
data = json.loads(request.body)
|
|
complaint_ids = data.get("complaint_ids", [])
|
|
new_status = data.get("status")
|
|
note = data.get("note", "")
|
|
|
|
if not complaint_ids or not new_status:
|
|
return JsonResponse({"success": False, "error": "Missing required fields"}, status=400)
|
|
|
|
result = bulk_change_status(complaint_ids, new_status, request.user, note)
|
|
|
|
if result["success"]:
|
|
messages.success(request, f"Successfully updated {result['success_count']} complaints.")
|
|
|
|
return JsonResponse(result)
|
|
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400)
|
|
except Exception as e:
|
|
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
|
|
|
|
|
@hospital_admin_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_bulk_escalate(request):
|
|
"""Bulk escalate complaints - Admin only"""
|
|
from apps.complaints.utils import bulk_escalate_complaints
|
|
import json
|
|
|
|
try:
|
|
data = json.loads(request.body)
|
|
complaint_ids = data.get("complaint_ids", [])
|
|
reason = data.get("reason", "")
|
|
|
|
if not complaint_ids:
|
|
return JsonResponse({"success": False, "error": "No complaints selected"}, status=400)
|
|
|
|
result = bulk_escalate_complaints(complaint_ids, request.user, reason)
|
|
|
|
if result["success"]:
|
|
messages.success(request, f"Successfully escalated {result['success_count']} complaints.")
|
|
|
|
return JsonResponse(result)
|
|
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400)
|
|
except Exception as e:
|
|
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
|
|
|
|
|
# ============================================================================
|
|
# INQUIRIES VIEWS
|
|
# ============================================================================
|
|
|
|
|
|
@login_required
|
|
def inquiry_list(request):
|
|
"""
|
|
Inquiries list view with filters and pagination.
|
|
"""
|
|
from .models import Inquiry
|
|
|
|
# Base queryset with optimizations
|
|
queryset = Inquiry.objects.select_related("patient", "hospital", "department", "outgoing_department", "assigned_to", "responded_by")
|
|
|
|
# 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():
|
|
if selected_hospital:
|
|
queryset = queryset.filter(hospital=selected_hospital)
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
elif user.is_px_management() or user.is_px_employee():
|
|
if user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
elif user.is_department_manager() and user.department:
|
|
queryset = queryset.filter(
|
|
Q(department=user.department) | Q(outgoing_department=user.department)
|
|
)
|
|
elif user.is_champion() and user.department:
|
|
queryset = queryset.filter(
|
|
Q(department=user.department) | Q(outgoing_department=user.department)
|
|
)
|
|
elif user.is_source_user():
|
|
queryset = queryset.filter(created_by=user)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
# Snapshot the RBAC base before direction/filters so we can compute
|
|
# incoming/outgoing counts for the tabs.
|
|
base_qs = queryset
|
|
|
|
# Apply non-direction filters (status, category, hospital, department, search)
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
base_qs = base_qs.filter(status=status_filter)
|
|
|
|
category_filter = request.GET.get("category")
|
|
if category_filter:
|
|
base_qs = base_qs.filter(category=category_filter)
|
|
|
|
hospital_filter = request.GET.get("hospital")
|
|
if hospital_filter:
|
|
base_qs = base_qs.filter(hospital_id=hospital_filter)
|
|
|
|
department_filter = request.GET.get("department")
|
|
if department_filter:
|
|
base_qs = base_qs.filter(department_id=department_filter)
|
|
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
base_qs = base_qs.filter(
|
|
Q(subject__icontains=search_query)
|
|
| Q(message__icontains=search_query)
|
|
| Q(contact_name__icontains=search_query)
|
|
| Q(contact_email__icontains=search_query)
|
|
)
|
|
|
|
# Direction split: incoming vs outgoing
|
|
direction = request.GET.get("direction", "incoming")
|
|
dept = getattr(user, "department", None)
|
|
is_dept_scoped = user.is_department_manager() or user.is_champion()
|
|
if is_dept_scoped and dept:
|
|
sent_away = Q(department=dept) & Q(transferred_to_department__isnull=False) & ~Q(transferred_to_department=dept)
|
|
incoming_qs = base_qs.filter(
|
|
Q(department=dept) | Q(transferred_to_department=dept) | Q(outgoing_department=dept)
|
|
).exclude(sent_away)
|
|
outgoing_qs = base_qs.filter(sent_away)
|
|
else:
|
|
# PX roles: incoming = not forwarded; outgoing = forwarded to a department
|
|
incoming_qs = base_qs.exclude(transferred_to_department__isnull=False)
|
|
outgoing_qs = base_qs.filter(transferred_to_department__isnull=False)
|
|
|
|
incoming_count = incoming_qs.count()
|
|
outgoing_count = outgoing_qs.count()
|
|
queryset = outgoing_qs if direction == "outgoing" else incoming_qs
|
|
|
|
# Ordering
|
|
order_by = request.GET.get("order_by", "-created_at")
|
|
queryset = queryset.order_by(order_by)
|
|
|
|
# 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)
|
|
|
|
# Statistics
|
|
total_count = queryset.count()
|
|
resolved_count = queryset.filter(status="resolved").count()
|
|
|
|
stats = {
|
|
"total": total_count,
|
|
"open": queryset.filter(status="open").count(),
|
|
"in_progress": queryset.filter(status="in_progress").count(),
|
|
"resolved": resolved_count,
|
|
"resolved_percentage": (resolved_count / total_count * 100) if total_count > 0 else 0,
|
|
"overdue": queryset.filter(is_overdue=True).count(),
|
|
}
|
|
|
|
# Get departments for filter
|
|
departments = Department.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
departments = departments.filter(hospital=user.hospital)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"inquiries": page_obj,
|
|
"stats": stats,
|
|
"departments": departments,
|
|
"filters": request.GET,
|
|
"direction": direction,
|
|
"incoming_count": incoming_count,
|
|
"outgoing_count": outgoing_count,
|
|
}
|
|
|
|
return render(request, "complaints/inquiry_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def inquiry_detail(request, pk):
|
|
"""
|
|
Inquiry detail view with timeline and attachments.
|
|
|
|
Features:
|
|
- Full inquiry details
|
|
- Timeline of all updates
|
|
- Attachments management
|
|
- Workflow actions (assign, status change, add note, respond)
|
|
"""
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
source_user = SourceUser.objects.filter(user=request.user).first()
|
|
base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html"
|
|
|
|
inquiry = get_object_or_404(
|
|
Inquiry.objects.select_related(
|
|
"patient", "hospital", "department",
|
|
"assigned_to", "responded_by",
|
|
"outgoing_department", "department_responded_by",
|
|
"taxonomy_domain", "taxonomy_category", "taxonomy_subcategory", "taxonomy_classification",
|
|
).prefetch_related("attachments", "updates__created_by"),
|
|
pk=pk,
|
|
)
|
|
|
|
# Check access
|
|
user = request.user
|
|
if not user.is_px_admin():
|
|
if user.is_px_management() or user.is_px_employee():
|
|
if user.hospital and inquiry.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to view this inquiry.")
|
|
return redirect("inquiries:inquiry_list")
|
|
elif user.is_hospital_admin() and inquiry.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to view this inquiry.")
|
|
return redirect("inquiries:inquiry_list")
|
|
elif (
|
|
user.is_champion() or user.is_department_manager()
|
|
) and (
|
|
(inquiry.department and inquiry.department == user.department)
|
|
or (inquiry.outgoing_department and inquiry.outgoing_department == user.department)
|
|
):
|
|
pass
|
|
elif (
|
|
user.is_champion() or user.is_department_manager()
|
|
):
|
|
messages.error(request, "You don't have permission to view this inquiry.")
|
|
return redirect("inquiries:inquiry_list")
|
|
elif user.hospital and inquiry.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to view this inquiry.")
|
|
return redirect("inquiries:inquiry_list")
|
|
|
|
# Get timeline (updates)
|
|
timeline = inquiry.updates.all().order_by("-created_at")
|
|
stage_timeline = _build_inquiry_stage_timeline(inquiry)
|
|
|
|
# Get attachments
|
|
attachments = inquiry.attachments.all().order_by("-created_at")
|
|
|
|
# Get assignable users
|
|
from apps.core.utils import get_assignable_users
|
|
assignable_users = get_assignable_users(inquiry.hospital)
|
|
|
|
# Get departments for the inquiry's hospital
|
|
hospital_departments = []
|
|
if inquiry.hospital:
|
|
from django.db.models import Q
|
|
|
|
hospital_departments = Department.objects.filter(
|
|
hospital=inquiry.hospital, status="active",
|
|
).filter(
|
|
Q(champion__isnull=False) | Q(manager__isnull=False)
|
|
).order_by("name")
|
|
|
|
# Status choices for the form
|
|
status_choices = [
|
|
("open", "Open"),
|
|
("in_progress", "In Progress"),
|
|
("contacted", "Contacted"),
|
|
("contacted_no_response", "Contacted, No Response"),
|
|
("resolved", "Resolved"),
|
|
("closed", "Closed"),
|
|
]
|
|
|
|
from apps.rca.models import RootCauseAnalysis as RCA
|
|
from django.contrib.contenttypes.models import ContentType as CT
|
|
|
|
inquiry_ct = CT.objects.get_for_model(Inquiry)
|
|
linked_rcas = RCA.objects.filter(content_type=inquiry_ct, object_id=inquiry.pk, is_deleted=False).select_related(
|
|
"assigned_to", "created_by"
|
|
)
|
|
|
|
escalation_targets = []
|
|
|
|
if inquiry.hospital:
|
|
from django.db.models import Q
|
|
|
|
escalation_targets_qs = (
|
|
Staff.objects.filter(
|
|
hospital=inquiry.hospital, status="active", user__isnull=False, user__is_active=True
|
|
)
|
|
.filter(Q(user__groups__name__in=["Hospital Admin", "Department Manager"]) | Q(direct_reports__isnull=False))
|
|
.select_related("user", "department", "report_to")
|
|
.distinct()
|
|
.order_by("first_name", "last_name")
|
|
)
|
|
|
|
for staff in escalation_targets_qs:
|
|
escalation_targets.append(
|
|
{
|
|
"staff": staff,
|
|
"has_user": True,
|
|
"user_id": str(staff.user.id),
|
|
"is_manager": staff.direct_reports.exists(),
|
|
"is_line_manager": False,
|
|
"group": "managers",
|
|
}
|
|
)
|
|
|
|
escalation_targets.sort(key=lambda x: (not x["is_manager"], x["staff"].get_full_name()))
|
|
|
|
if inquiry.department:
|
|
dept = Department.objects.filter(pk=inquiry.department_id).first()
|
|
if dept:
|
|
dept_role_holders = []
|
|
for holder in dept.get_role_holders():
|
|
if holder["staff"].user and holder["staff"].user.is_active:
|
|
dept_role_holders.append({
|
|
"staff": holder["staff"],
|
|
"has_user": True,
|
|
"user_id": str(holder["staff"].user.id),
|
|
"is_manager": False,
|
|
"is_line_manager": False,
|
|
"role_label": holder["role_label"],
|
|
"group": "department_roles",
|
|
})
|
|
if dept_role_holders:
|
|
escalation_targets = dept_role_holders + escalation_targets
|
|
|
|
context = {
|
|
"inquiry": inquiry,
|
|
"timeline": timeline,
|
|
"stage_timeline": stage_timeline,
|
|
"attachments": attachments,
|
|
"assignable_users": assignable_users,
|
|
"send_to_users": User.objects.filter(
|
|
is_active=True, hospital=inquiry.hospital
|
|
).select_related("department").order_by("first_name", "last_name"),
|
|
"hospital_departments": hospital_departments,
|
|
"status_choices": status_choices,
|
|
"can_edit": (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or user.is_px_management()
|
|
or user.is_px_employee()
|
|
),
|
|
"can_respond": (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or user.is_px_management()
|
|
or user.is_px_employee()
|
|
or inquiry.assigned_to == user
|
|
or (
|
|
user.is_champion()
|
|
and user.department
|
|
and user.department in [inquiry.department, inquiry.outgoing_department]
|
|
)
|
|
),
|
|
"can_send_reminder": (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or user.is_px_management()
|
|
or user.is_px_employee()
|
|
),
|
|
"can_admin": user.is_px_admin() or user.is_hospital_admin(),
|
|
"base_layout": base_layout,
|
|
"source_user": source_user,
|
|
"linked_rcas": linked_rcas,
|
|
"escalation_targets": escalation_targets,
|
|
"escalation_email_subject": f"Inquiry Escalated - {inquiry.reference_number} - {inquiry.subject or 'N/A'}",
|
|
"escalation_email_body": (
|
|
f"Dear Manager,\n\n"
|
|
f"This inquiry has been escalated and requires your immediate attention.\n\n"
|
|
f"Reference: {inquiry.reference_number}\n"
|
|
f"Subject: {inquiry.subject or 'N/A'}\n"
|
|
f"Priority: {inquiry.get_priority_display()}\n"
|
|
f"Status: {inquiry.get_status_display()}\n"
|
|
f"Department: {inquiry.department.get_localized_name() if inquiry.department else 'N/A'}\n\n"
|
|
f"Please review and take appropriate action.\n\n"
|
|
f"View: https://{request.get_host()}/inquiries/{inquiry.pk}/"
|
|
),
|
|
}
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
inquiry_ct = ContentType.objects.get_for_model(inquiry)
|
|
context["content_type_id"] = inquiry_ct.pk
|
|
context["object_id"] = inquiry.pk
|
|
context["notes"] = inquiry.notes.select_related("created_by").all()
|
|
context["notes_count"] = context["notes"].count()
|
|
|
|
# Workflow stepper
|
|
_iq_steps = [
|
|
{"label": "Created", "icon": "plus-circle", "done": True},
|
|
{"label": "Assigned", "icon": "user-plus", "done": inquiry.assigned_at is not None},
|
|
{"label": "Sent to Dept", "icon": "send", "done": inquiry.sent_to_department_at is not None},
|
|
{"label": "Dept Response", "icon": "message-square", "done": inquiry.department_responded_at is not None},
|
|
{"label": "Resolved", "icon": "check-circle-2", "done": inquiry.status in ("resolved", "closed")},
|
|
]
|
|
_iq_next = next((i for i, s in enumerate(_iq_steps) if not s["done"]), len(_iq_steps))
|
|
if _iq_next < len(_iq_steps):
|
|
_iq_steps[_iq_next]["current"] = True
|
|
context["workflow_steps"] = _iq_steps
|
|
|
|
return render(request, "complaints/inquiry_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_send_to_staff(request, pk):
|
|
"""Send inquiry explanation request to a specific staff member via token link."""
|
|
from .models import InquiryExplanation
|
|
from apps.organizations.models import Staff
|
|
from apps.notifications.services import NotificationService
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
import secrets
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
if not (
|
|
request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
or request.user.is_px_management() or request.user.is_px_employee()
|
|
):
|
|
messages.error(request, _("You don't have permission to perform this action."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
staff_id = request.POST.get("staff_id")
|
|
request_message = request.POST.get("request_message", "").strip()
|
|
|
|
if not staff_id:
|
|
messages.error(request, _("Please select a staff member."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
staff = get_object_or_404(Staff, pk=staff_id)
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
explanation = InquiryExplanation.objects.create(
|
|
inquiry=inquiry,
|
|
staff=staff,
|
|
token=token,
|
|
requested_by=request.user,
|
|
request_message=request_message,
|
|
email_sent_at=timezone.now(),
|
|
)
|
|
|
|
from apps.complaints.models import InquirySLAConfig
|
|
sla_config = InquirySLAConfig.get_active_config()
|
|
if sla_config and sla_config.dept_response_sla_hours:
|
|
explanation.sla_due_at = timezone.now() + timezone.timedelta(hours=sla_config.dept_response_sla_hours)
|
|
explanation.save(update_fields=["sla_due_at"])
|
|
|
|
site = get_current_site(request)
|
|
explanation_url = f"https://{site.domain}/inquiries/{inquiry.id}/explain/{token}/"
|
|
|
|
if staff.email:
|
|
from django.template.loader import render_to_string
|
|
|
|
email_context = {
|
|
"staff_name": f"{staff.first_name} {staff.last_name}",
|
|
"inquiry_subject": inquiry.subject,
|
|
"inquiry_reference": inquiry.reference_number or str(inquiry.id),
|
|
"inquiry_message": inquiry.message[:500],
|
|
"request_message": request_message,
|
|
"explanation_url": explanation_url,
|
|
"site_name": site.name,
|
|
}
|
|
html_message = render_to_string("emails/inquiry_explanation_request.html", email_context)
|
|
message = (
|
|
f"Dear {staff.first_name} {staff.last_name},\n\n"
|
|
f"You have been requested to provide a response regarding inquiry #{inquiry.reference_number or inquiry.id}.\n\n"
|
|
f"Subject: {inquiry.subject}\n\n"
|
|
f"Please submit your response using the link below:\n{explanation_url}\n"
|
|
)
|
|
NotificationService.send_email(
|
|
email=staff.email,
|
|
subject=f"Inquiry Response Requested - #{inquiry.reference_number or inquiry.id}",
|
|
message=message,
|
|
html_message=html_message,
|
|
related_object=explanation,
|
|
metadata={"notification_type": "inquiry_explanation_request"},
|
|
)
|
|
|
|
messages.success(request, _(f"Response request sent to {staff.first_name} {staff.last_name}"))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_quick_create(request):
|
|
"""Quick-create an inquiry from the simple modal (PX staff only).
|
|
|
|
Fields: message, contact name, phone (required), email (optional),
|
|
location type, category, department, section. The inquiry is auto-activated
|
|
and assigned to the current user. AI fills subject (from its title) and
|
|
priority in the background; the user's department/section/category are kept.
|
|
"""
|
|
from .models import Inquiry, InquiryUpdate
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
return JsonResponse(
|
|
{"success": False, "error": str(_("You don't have permission to create inquiries."))},
|
|
status=403,
|
|
)
|
|
|
|
message = (request.POST.get("message") or "").strip()
|
|
contact_name = (request.POST.get("contact_name") or "").strip()
|
|
contact_phone = (request.POST.get("contact_phone") or "").strip()
|
|
contact_email = (request.POST.get("contact_email") or "").strip()
|
|
location_type = (request.POST.get("location_type") or "").strip()
|
|
department_id = (request.POST.get("department") or "").strip()
|
|
section_id = (request.POST.get("section") or "").strip()
|
|
|
|
# Required-field validation (the inquiry-type `category` is intentionally NOT
|
|
# collected here — the model field is required at DB level so it is defaulted
|
|
# to "general" below. The department-category in the modal is a UI filter only.)
|
|
missing = {
|
|
"message": message,
|
|
"contact_name": contact_name,
|
|
"contact_phone": contact_phone,
|
|
"location_type": location_type,
|
|
"department": department_id,
|
|
}
|
|
for field, value in missing.items():
|
|
if not value:
|
|
return JsonResponse(
|
|
{"success": False, "error": str(_("This field is required: {}").format(field))},
|
|
status=400,
|
|
)
|
|
|
|
hospital = getattr(request, "tenant_hospital", None) or user.hospital
|
|
if not hospital:
|
|
return JsonResponse({"success": False, "error": str(_("No hospital assigned to your account."))}, status=400)
|
|
|
|
# Validate location_type
|
|
from apps.complaints.models import Inquiry as _Inquiry
|
|
valid_location_types = {c for c, _ in _Inquiry._meta.get_field("location_type").choices or []}
|
|
if valid_location_types and location_type not in valid_location_types:
|
|
return JsonResponse({"success": False, "error": str(_("Invalid location type."))}, status=400)
|
|
|
|
# Validate department belongs to the hospital
|
|
from apps.organizations.models import Department, Section
|
|
try:
|
|
department = Department.objects.get(id=department_id, hospital=hospital, status="active")
|
|
except Department.DoesNotExist:
|
|
return JsonResponse({"success": False, "error": str(_("Invalid department for this hospital."))}, status=400)
|
|
|
|
# Section is optional — only validate if provided
|
|
section = None
|
|
if section_id:
|
|
try:
|
|
section = Section.objects.get(id=section_id, department=department)
|
|
except Section.DoesNotExist:
|
|
return JsonResponse(
|
|
{"success": False, "error": str(_("Selected section does not belong to this department."))},
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
inquiry = Inquiry.objects.create(
|
|
hospital=hospital,
|
|
created_by=user,
|
|
message=message,
|
|
contact_name=contact_name,
|
|
contact_phone=contact_phone,
|
|
contact_email=contact_email,
|
|
location_type=location_type,
|
|
category="general",
|
|
department=department,
|
|
section=section,
|
|
# The inquiry is routed to this department for handling — mark it as
|
|
# sent so it appears in the department's incoming queue (not outgoing).
|
|
sent_to_department=True,
|
|
sent_to_department_at=timezone.now(),
|
|
# Defaults — AI refines subject/priority in the background (NOT department)
|
|
subject=message[:80],
|
|
priority="medium",
|
|
# Auto-activate + auto-assign to the creator
|
|
status="in_progress",
|
|
assigned_to=user,
|
|
assigned_at=timezone.now(),
|
|
activated_at=timezone.now(),
|
|
metadata={
|
|
"subject_pending_ai": True, # AI fills the subject from its generated title
|
|
"ai_skip_department": True, # user provided department — AI must not override it
|
|
},
|
|
)
|
|
|
|
# Background AI analysis (fills subject/priority/department) + notifications
|
|
from apps.complaints.tasks import analyze_inquiry_with_ai, notify_staff_new_item
|
|
analyze_inquiry_with_ai.delay(str(inquiry.id))
|
|
notify_staff_new_item.delay("inquiry", str(inquiry.id))
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message="Inquiry created.",
|
|
created_by=user,
|
|
)
|
|
roles_display = ", ".join(user.get_role_names()) if hasattr(user, "get_role_names") else ""
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="assignment",
|
|
message=f"Inquiry auto-activated and assigned to {user.get_full_name()} ({roles_display})",
|
|
created_by=user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_created",
|
|
description=f"Quick inquiry created: {inquiry.subject}",
|
|
user=user,
|
|
content_object=inquiry,
|
|
metadata={"category": inquiry.category, "quick_create": True},
|
|
)
|
|
|
|
return JsonResponse({
|
|
"success": True,
|
|
"inquiry_id": str(inquiry.id),
|
|
"reference_number": inquiry.reference_number,
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Quick inquiry create failed: {e}")
|
|
return JsonResponse(
|
|
{"success": False, "error": str(_("An error occurred while creating the inquiry."))},
|
|
status=500,
|
|
)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def inquiry_create(request):
|
|
"""Create new inquiry"""
|
|
from .models import Inquiry
|
|
from .forms import InquiryForm
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
# Determine base layout based on user type
|
|
source_user = SourceUser.objects.filter(user=request.user).first()
|
|
base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html"
|
|
communication_request = None
|
|
|
|
if request.method == "POST":
|
|
form = InquiryForm(request.POST, request=request)
|
|
|
|
if form.is_valid():
|
|
try:
|
|
inquiry = form.save(commit=False)
|
|
|
|
inquiry.category = request.POST.get("category")
|
|
inquiry.created_by = request.user
|
|
|
|
is_outgoing = request.POST.get("is_outgoing") in ("true", "True", "on", "1")
|
|
inquiry.is_outgoing = is_outgoing
|
|
|
|
if is_outgoing:
|
|
outgoing_dept_id = request.POST.get("outgoing_department")
|
|
if outgoing_dept_id:
|
|
from apps.organizations.models import Department
|
|
try:
|
|
inquiry.outgoing_department = Department.objects.get(id=outgoing_dept_id)
|
|
except Department.DoesNotExist:
|
|
pass
|
|
|
|
# Link to patient if auto-lookup found a match
|
|
patient_id = request.POST.get("patient_id")
|
|
if patient_id:
|
|
try:
|
|
from uuid import UUID
|
|
|
|
UUID(patient_id)
|
|
inquiry.patient_id = patient_id
|
|
except (ValueError, AttributeError):
|
|
pass
|
|
|
|
inquiry.save()
|
|
|
|
# Background patient linking (local DB first, then HIS).
|
|
if not inquiry.patient_id:
|
|
from .tasks import link_inquiry_patient
|
|
|
|
link_inquiry_patient.delay(str(inquiry.id), inquiry.contact_phone)
|
|
|
|
comm_req_id = request.POST.get("comm_req")
|
|
if comm_req_id:
|
|
try:
|
|
from apps.px_sources.models import CommunicationRequest as CR
|
|
cr = CR.objects.get(pk=comm_req_id)
|
|
cr.link_to_record(inquiry)
|
|
except Exception:
|
|
pass
|
|
|
|
from apps.complaints.tasks import analyze_inquiry_with_ai, notify_staff_new_item
|
|
|
|
analyze_inquiry_with_ai.delay(str(inquiry.id))
|
|
notify_staff_new_item.delay("inquiry", str(inquiry.id))
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message="Inquiry created.",
|
|
created_by=request.user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_created",
|
|
description=f"Inquiry created: {inquiry.subject}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
metadata={"category": inquiry.category},
|
|
)
|
|
|
|
messages.success(request, f"Inquiry #{inquiry.id} created successfully.")
|
|
return redirect("inquiries:inquiry_detail", pk=inquiry.id)
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Error creating inquiry: {str(e)}")
|
|
else:
|
|
messages.error(request, f"Please correct the errors: {form.errors}")
|
|
else:
|
|
# GET request - show form
|
|
# Check for hospital parameter from URL (for pre-selection)
|
|
initial_data = {}
|
|
hospital_id = request.GET.get("hospital")
|
|
if hospital_id:
|
|
initial_data["hospital"] = hospital_id
|
|
|
|
comm_req_id = request.GET.get("comm_req")
|
|
if comm_req_id:
|
|
try:
|
|
from apps.px_sources.models import CommunicationRequest
|
|
cr_data = CommunicationRequest.get_initial_data(comm_req_id)
|
|
communication_request = cr_data["communication_request"]
|
|
for key in ("hospital", "contact_name", "contact_phone", "message"):
|
|
if key in cr_data["initial"] and cr_data["initial"][key]:
|
|
initial_data[key] = cr_data["initial"][key]
|
|
except Exception:
|
|
pass
|
|
|
|
form = InquiryForm(request=request, initial=initial_data)
|
|
|
|
context = {
|
|
"form": form,
|
|
"base_layout": base_layout,
|
|
"source_user": source_user,
|
|
"communication_request": communication_request,
|
|
}
|
|
|
|
return render(request, "complaints/inquiry_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def inquiry_edit(request, pk):
|
|
"""Edit existing inquiry"""
|
|
from .forms import InquiryForm
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
messages.error(request, _("You don't have permission to edit this inquiry."))
|
|
return redirect("inquiries:inquiry_detail", pk=inquiry.pk)
|
|
|
|
if inquiry.status == "closed":
|
|
messages.error(request, _("Closed inquiries cannot be edited."))
|
|
return redirect("inquiries:inquiry_detail", pk=inquiry.pk)
|
|
|
|
if request.method == "POST":
|
|
form = InquiryForm(request.POST, request=request, instance=inquiry)
|
|
|
|
if form.is_valid():
|
|
try:
|
|
inquiry = form.save(commit=False)
|
|
|
|
inquiry.category = request.POST.get("category", inquiry.category)
|
|
|
|
is_outgoing = request.POST.get("is_outgoing") in ("true", "True", "on", "1")
|
|
inquiry.is_outgoing = is_outgoing
|
|
|
|
if is_outgoing:
|
|
outgoing_dept_id = request.POST.get("outgoing_department")
|
|
if outgoing_dept_id:
|
|
try:
|
|
inquiry.outgoing_department = Department.objects.get(id=outgoing_dept_id)
|
|
except Department.DoesNotExist:
|
|
pass
|
|
else:
|
|
inquiry.outgoing_department = None
|
|
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message="Inquiry updated.",
|
|
created_by=request.user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_updated",
|
|
description=f"Inquiry updated: {inquiry.subject}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
)
|
|
|
|
messages.success(request, _("Inquiry updated successfully."))
|
|
return redirect("inquiries:inquiry_detail", pk=inquiry.pk)
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Error updating inquiry: {str(e)}")
|
|
else:
|
|
messages.error(request, f"Please correct the errors: {form.errors}")
|
|
else:
|
|
initial_data = {}
|
|
form = InquiryForm(request=request, instance=inquiry, initial=initial_data)
|
|
|
|
context = {
|
|
"form": form,
|
|
"inquiry": inquiry,
|
|
}
|
|
|
|
return render(request, "complaints/inquiry_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_activate(request, pk):
|
|
"""Activate inquiry by assigning it to current logged-in user"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
messages.error(request, "You don't have permission to activate inquiries.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
# Check if already assigned to current user
|
|
if inquiry.assigned_to == user:
|
|
messages.info(request, "This inquiry is already assigned to you.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
old_assignee = inquiry.assigned_to
|
|
old_status = inquiry.status
|
|
|
|
# Update inquiry
|
|
inquiry.assigned_to = user
|
|
inquiry.assigned_at = timezone.now()
|
|
|
|
# Only change status to in_progress if it's currently open
|
|
if inquiry.status == "open":
|
|
inquiry.status = "in_progress"
|
|
inquiry.activated_at = timezone.now()
|
|
inquiry.save(update_fields=["assigned_to", "assigned_at", "status", "activated_at"])
|
|
else:
|
|
inquiry.save(update_fields=["assigned_to", "assigned_at"])
|
|
|
|
# Create update
|
|
roles_display = ", ".join(user.get_role_names())
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="assignment",
|
|
message=f"Inquiry activated and assigned to {user.get_full_name()} ({roles_display})",
|
|
created_by=request.user,
|
|
metadata={
|
|
"old_assignee_id": str(old_assignee.id) if old_assignee else None,
|
|
"new_assignee_id": str(user.id),
|
|
"assignee_roles": user.get_role_names(),
|
|
"old_status": old_status,
|
|
"new_status": inquiry.status,
|
|
"activated_by_current_user": True,
|
|
},
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="inquiry_activated",
|
|
description=f"Inquiry activated by {user.get_full_name()}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
metadata={
|
|
"old_assignee_id": str(old_assignee.id) if old_assignee else None,
|
|
"new_assignee_id": str(user.id),
|
|
"old_status": old_status,
|
|
"new_status": inquiry.status,
|
|
},
|
|
)
|
|
|
|
messages.success(request, f"Inquiry activated and assigned to you successfully.")
|
|
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_assign(request, pk):
|
|
"""Assign inquiry to user"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
can_assign = (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or (user.is_department_manager() and user.department and user.department == inquiry.department)
|
|
or user.is_px_management()
|
|
or user.is_px_employee()
|
|
)
|
|
if not can_assign:
|
|
messages.error(request, "You don't have permission to assign inquiries.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
user_id = request.POST.get("user_id")
|
|
if not user_id:
|
|
messages.error(request, "Please select a user to assign.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
try:
|
|
assignee = User.objects.get(id=user_id)
|
|
old_assignee = inquiry.assigned_to
|
|
old_status = inquiry.status
|
|
|
|
inquiry.assigned_to = assignee
|
|
inquiry.assigned_at = timezone.now()
|
|
|
|
reopened = False
|
|
if old_status in ("resolved", "closed"):
|
|
inquiry.status = "in_progress"
|
|
reopened = True
|
|
|
|
inquiry.save()
|
|
|
|
msg = f"{'Reopened and a' if reopened else 'A'}ssigned to {assignee.get_full_name()}"
|
|
if old_assignee:
|
|
msg += f" (reassigned from {old_assignee.get_full_name()})"
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="assignment",
|
|
message=msg,
|
|
created_by=request.user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="assignment",
|
|
description=f"Inquiry {'reopened and ' if reopened else ''}assigned to {assignee.get_full_name()}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
metadata={"old_status": old_status, "reopened": reopened},
|
|
)
|
|
|
|
if inquiry.department:
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_inquiry_department_assigned(inquiry.department, inquiry)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send department assignment notification: {e}")
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_inquiry_assigned(inquiry)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send inquiry assignment notification: {e}")
|
|
|
|
messages.success(request, f"Inquiry {'reopened and ' if reopened else ''}assigned to {assignee.get_full_name()}.")
|
|
|
|
except User.DoesNotExist:
|
|
messages.error(request, "User not found.")
|
|
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_change_status(request, pk):
|
|
"""Change inquiry status"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
or (user.is_department_manager() and user.department_id == inquiry.handling_department_id)
|
|
or (user.is_champion() and user.department_id == inquiry.handling_department_id)
|
|
):
|
|
messages.error(request, "You don't have permission to change inquiry status.")
|
|
return _safe_next_redirect(request, redirect("inquiries:inquiry_detail", pk=pk))
|
|
|
|
new_status = request.POST.get("status")
|
|
note = request.POST.get("note", "")
|
|
|
|
if not new_status:
|
|
messages.error(request, "Please select a status.")
|
|
return _safe_next_redirect(request, redirect("inquiries:inquiry_detail", pk=pk))
|
|
|
|
old_status = inquiry.status
|
|
inquiry.status = new_status
|
|
|
|
# Handle status-specific logic
|
|
# Department users (champion / dept manager) must record a patient contact
|
|
# outcome before closing/resolving an inquiry. PX roles follow their own flow.
|
|
if new_status in ("resolved", "closed") and (user.is_champion() or user.is_department_manager()):
|
|
if inquiry.contact_status not in ("contacted", "contacted_no_response"):
|
|
messages.error(request, _("Please record a patient contact outcome before closing."))
|
|
return _safe_next_redirect(request, redirect("inquiries:inquiry_detail", pk=pk))
|
|
|
|
# "Contacted - No Response" is a terminal status set when the department could
|
|
# not reach the patient. Auto-sync the contact_status tracking so outreach data
|
|
# stays consistent without double data entry.
|
|
if new_status == "contact_no_response":
|
|
inquiry.contact_status = "contacted_no_response"
|
|
inquiry.contact_status_at = timezone.now()
|
|
inquiry.contact_status_by = user
|
|
|
|
inquiry._acting_user = request.user
|
|
inquiry.save()
|
|
|
|
# Create update
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="status_change",
|
|
message=note or f"Status changed from {old_status} to {new_status}",
|
|
created_by=request.user,
|
|
old_status=old_status,
|
|
new_status=new_status,
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="status_change",
|
|
description=f"Inquiry status changed from {old_status} to {new_status}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
metadata={"old_status": old_status, "new_status": new_status},
|
|
)
|
|
|
|
messages.success(request, f"Inquiry status changed to {new_status}.")
|
|
return _safe_next_redirect(request, redirect("inquiries:inquiry_detail", pk=pk))
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_set_contact_status(request, pk):
|
|
"""Record the outcome of the department's patient-contact attempt.
|
|
|
|
Sets the existing ``contact_status`` field (not_contacted / contacted /
|
|
contacted_no_response). Used by the department (champion/manager) that owns
|
|
the inquiry to log communication outcomes with the patient.
|
|
"""
|
|
from .models import Inquiry, InquiryUpdate
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
or (user.is_department_manager() and user.department_id == inquiry.handling_department_id)
|
|
or (user.is_champion() and user.department_id == inquiry.handling_department_id)
|
|
):
|
|
messages.error(request, _("You don't have permission to set the contact status."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
new_contact_status = request.POST.get("contact_status")
|
|
note = request.POST.get("note", "").strip()
|
|
|
|
valid = {c for c, _ in Inquiry._meta.get_field("contact_status").choices}
|
|
if new_contact_status not in valid:
|
|
messages.error(request, _("Please select a valid contact status."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
old_contact_status = inquiry.contact_status
|
|
inquiry.contact_status = new_contact_status
|
|
inquiry.contact_status_at = timezone.now()
|
|
inquiry.contact_status_by = user
|
|
inquiry.save(update_fields=["contact_status", "contact_status_at", "contact_status_by"])
|
|
|
|
display = dict(Inquiry._meta.get_field("contact_status").choices).get(new_contact_status, new_contact_status)
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="communication",
|
|
message=note or _("Contact status set to: {status}").format(status=display),
|
|
created_by=user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_contact_status",
|
|
description=_("Contact status changed from {old} to {new}").format(
|
|
old=old_contact_status or "—", new=new_contact_status
|
|
),
|
|
user=user,
|
|
content_object=inquiry,
|
|
metadata={"old_contact_status": old_contact_status, "new_contact_status": new_contact_status},
|
|
)
|
|
|
|
messages.success(request, _("Contact status updated to: {status}").format(status=display))
|
|
|
|
return _safe_next_redirect(request, redirect("inquiries:inquiry_detail", pk=pk))
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_reopen(request, pk):
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or (user.is_department_manager() and user.department == inquiry.department)
|
|
or user.is_px_management()
|
|
or user.is_px_employee()
|
|
):
|
|
messages.error(request, "You don't have permission to reopen inquiries.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
if inquiry.status not in ("resolved", "closed"):
|
|
messages.error(request, "Only resolved or closed inquiries can be reopened.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
old_status = inquiry.status
|
|
note = request.POST.get("note", "Inquiry reopened")
|
|
|
|
inquiry.status = "in_progress"
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="status_change",
|
|
message=note or f"Inquiry reopened from {old_status}",
|
|
created_by=user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_reopened",
|
|
description=f"Inquiry reopened from {old_status} by {user.get_full_name()}",
|
|
user=user,
|
|
content_object=inquiry,
|
|
metadata={"old_status": old_status, "new_status": "in_progress"},
|
|
)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_inquiry_reopened(inquiry)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send inquiry reopened notification: {e}")
|
|
|
|
messages.success(request, "Inquiry reopened successfully.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_add_note(request, pk):
|
|
"""Add note to inquiry"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
note = request.POST.get("note")
|
|
if not note:
|
|
messages.error(request, "Please enter a note.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
# Create update
|
|
InquiryUpdate.objects.create(inquiry=inquiry, update_type="note", message=note, created_by=request.user)
|
|
|
|
messages.success(request, "Note added successfully.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_respond(request, pk):
|
|
"""Respond to inquiry with bilingual support and notification"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
can_respond = (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or inquiry.assigned_to == user
|
|
or (
|
|
user.is_champion()
|
|
and user.department
|
|
and user.department in [inquiry.department, inquiry.outgoing_department]
|
|
)
|
|
or user.is_px_management()
|
|
or user.is_px_employee()
|
|
)
|
|
if not can_respond:
|
|
messages.error(request, "You don't have permission to respond to inquiries.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
response = request.POST.get("response", "").strip()
|
|
|
|
if not response:
|
|
messages.error(request, "Please enter a response.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
inquiry.response = response
|
|
inquiry.response_en = ""
|
|
inquiry.response_ar = ""
|
|
inquiry.responded_at = timezone.now()
|
|
inquiry.responded_by = request.user
|
|
inquiry.status = "resolved"
|
|
inquiry._acting_user = request.user
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry, update_type="response", message="Response sent", created_by=request.user
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_responded",
|
|
description=f"Inquiry responded to: {inquiry.subject}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_inquiry_resolved(inquiry)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send inquiry resolved notification: {e}")
|
|
|
|
if inquiry.response_sent_at is None:
|
|
inquiry.response_sent_at = timezone.now()
|
|
inquiry.save(update_fields=["response_sent_at"])
|
|
|
|
try:
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
from apps.core.utils import build_public_track_url
|
|
|
|
track_url = build_public_track_url("inquiry", inquiry.reference_number)
|
|
|
|
if inquiry.contact_phone:
|
|
NotificationService.send_sms(
|
|
phone=inquiry.contact_phone,
|
|
message=f"PX360: Your inquiry #{inquiry.reference_number} has been responded to. View details: {track_url}",
|
|
related_object=inquiry,
|
|
metadata={"notification_type": "inquiry_response_sent"},
|
|
)
|
|
|
|
if inquiry.contact_email:
|
|
email_subject = f"PX360: Response to Your Inquiry #{inquiry.reference_number}"
|
|
email_body = (
|
|
f"Dear Valued Patient,\n\n"
|
|
f"Your inquiry #{inquiry.reference_number} has been responded to.\n\n"
|
|
f"To view the full response, please visit:\n{track_url}\n\n"
|
|
f"Thank you for your patience.\n\n"
|
|
f"Reference: {inquiry.reference_number}\n"
|
|
f"This is an automated message from PX 360."
|
|
)
|
|
NotificationService.send_email(
|
|
email=inquiry.contact_email,
|
|
subject=email_subject,
|
|
message=email_body,
|
|
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;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Response to Your Inquiry</h2>
|
|
<p style="margin: 0 0 12px 0;">Dear Valued Patient,</p>
|
|
<p style="margin: 0 0 12px 0;">Your inquiry <strong>#{inquiry.reference_number}</strong> has been responded to.</p>
|
|
<p style="margin: 0 0 12px 0;">To view the full response, please click the link below:</p>
|
|
<div style="text-align: center; margin: 20px 0;">
|
|
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Response</a>
|
|
</div>
|
|
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">Reference: {inquiry.reference_number}</p>
|
|
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">Thank you for your patience.</p>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=inquiry,
|
|
metadata={"notification_type": "inquiry_response_email"},
|
|
)
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="communication",
|
|
message=f"Response notification sent to inquirer (phone: {bool(inquiry.contact_phone)}, email: {bool(inquiry.contact_email)})",
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send inquiry response notification: {e}")
|
|
|
|
messages.success(request, "Response sent successfully.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_transfer_to_department(request, pk):
|
|
from .models import Inquiry, InquiryUpdate
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or (user.is_department_manager() and user.department_id == inquiry.handling_department_id)
|
|
or user.is_px_management() or user.is_px_employee()
|
|
or (user.is_champion() and user.department_id == inquiry.handling_department_id)
|
|
):
|
|
messages.error(request, _("You don't have permission to transfer inquiries to departments."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
# Must be activated (in_progress) before it can be sent to a department
|
|
if inquiry.status == "open":
|
|
messages.error(request, _("Activate this inquiry before sending it to a department."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
department_id = request.POST.get("department_id")
|
|
if not department_id:
|
|
messages.error(request, _("Please select a department."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
from apps.organizations.models import Department
|
|
try:
|
|
department = Department.objects.select_related("champion", "manager").get(pk=department_id, status="active")
|
|
except Department.DoesNotExist:
|
|
messages.error(request, _("Department not found."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
if not department.champion and not department.manager:
|
|
messages.error(request, _(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
contact_person_id = request.POST.get("contact_person_id")
|
|
if not contact_person_id:
|
|
messages.error(request, _("Please select a contact person."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
contact_info = department.is_valid_contact_person(contact_person_id)
|
|
if not contact_info:
|
|
messages.error(request, _("Selected person is not a role holder in this department."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
contact_person = contact_info["staff"]
|
|
|
|
recipient_type = request.POST.get("recipient_type", "staff")
|
|
|
|
note_en = request.POST.get("note_en", "").strip()
|
|
note_ar = request.POST.get("note_ar", "").strip()
|
|
|
|
note_parts = []
|
|
if note_en:
|
|
note_parts.append(note_en)
|
|
if note_ar:
|
|
note_parts.append(note_ar)
|
|
combined_note = "\n\n".join(note_parts)
|
|
|
|
inquiry.outgoing_department = department
|
|
inquiry.transferred_at = timezone.now()
|
|
inquiry.transferred_by = user
|
|
inquiry.transferred_to_department = department
|
|
inquiry.transfer_count = (inquiry.transfer_count or 0) + 1
|
|
# keep the cross-module "sent" signal consistent with observations/complaints
|
|
inquiry.sent_to_department = True
|
|
inquiry.sent_to_department_at = inquiry.transferred_at
|
|
|
|
sla_config = inquiry.get_sla_config()
|
|
if sla_config and sla_config.dept_response_hours:
|
|
from datetime import timedelta
|
|
inquiry.dept_response_sla_due_at = timezone.now() + timedelta(hours=sla_config.dept_response_hours)
|
|
inquiry.dept_response_is_overdue = False
|
|
inquiry.dept_response_reminder_sent_at = None
|
|
inquiry.dept_response_second_reminder_sent_at = None
|
|
inquiry.dept_response_escalated_at = None
|
|
|
|
if inquiry.status in ("open",):
|
|
inquiry.contact_status = "contacted"
|
|
inquiry.contact_status_at = timezone.now()
|
|
inquiry.contact_status_by = user
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="transferred_to_department",
|
|
message=combined_note or f"Inquiry transferred to {department.get_localized_name()} for response",
|
|
created_by=user,
|
|
)
|
|
|
|
try:
|
|
import secrets
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
|
|
# Generate token for response link
|
|
response_token = secrets.token_urlsafe(32)
|
|
inquiry.response_token = response_token
|
|
inquiry.response_token_sent_at = timezone.now()
|
|
inquiry.save(update_fields=["response_token", "response_token_sent_at"])
|
|
|
|
# Build response link
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
current_site = get_current_site(request)
|
|
domain = current_site.domain if current_site else request.get_host()
|
|
response_link = f"https://{domain}/inquiries/{inquiry.pk}/respond/{response_token}/"
|
|
|
|
NotificationServiceWithSettings.send_inquiry_department_assigned(
|
|
department, inquiry, context_note_en=note_en, context_note_ar=note_ar,
|
|
recipient_type=recipient_type,
|
|
)
|
|
|
|
contact_email = contact_person.email or (contact_person.user.email if contact_person.user else None)
|
|
if contact_email:
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
NotificationService.send_email(
|
|
email=contact_email,
|
|
subject=f"Inquiry #{inquiry.reference_number} - Response Required",
|
|
message=f"An inquiry has been transferred to your department ({department.get_localized_name()}) for response.\n\nSubject: {inquiry.subject}\n\nSubmit your response here: {response_link}\n\nDeadline: {inquiry.dept_response_sla_due_at}",
|
|
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;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Inquiry #{inquiry.reference_number} - Response Required</h2>
|
|
<p style="margin: 0 0 12px 0;">An inquiry has been transferred to your department (<strong>{department.get_localized_name()}</strong>) for response.</p>
|
|
<table style="width: 100%; border-collapse: collapse; margin: 0 0 12px 0;">
|
|
<tr><td style="padding: 4px 0; color: #6b7280; width: 120px;">Subject:</td><td style="padding: 4px 0;">{inquiry.subject}</td></tr>
|
|
<tr><td style="padding: 4px 0; color: #6b7280;">Deadline:</td><td style="padding: 4px 0;">{inquiry.dept_response_sla_due_at}</td></tr>
|
|
</table>
|
|
<div style="text-align: center; margin: 20px 0;">
|
|
<a href="{response_link}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Submit Response</a>
|
|
</div>
|
|
<p style="margin: 0; color: #6b7280; font-size: 13px;">This link can only be used once. After submission, it will expire.</p>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=inquiry,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send department notification: {e}")
|
|
|
|
if recipient_type == "department_email":
|
|
messages.success(request, _("Inquiry transferred to %(dept)s department email.") % {"dept": department.get_localized_name()})
|
|
else:
|
|
messages.success(request, _("Inquiry transferred to %(dept)s. Department respondents have been notified.") % {"dept": department.get_localized_name()})
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_escalate(request, pk):
|
|
from .models import Inquiry, InquiryUpdate
|
|
from apps.organizations.models import Staff
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
messages.error(request, _("You don't have permission to escalate inquiries."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
if inquiry.status in ("closed", "cancelled"):
|
|
messages.error(request, _("Cannot escalate a closed or cancelled inquiry."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
escalate_to_id = request.POST.get("escalate_to", "")
|
|
reason = request.POST.get("reason", "")
|
|
|
|
escalate_to_staff = None
|
|
escalate_to_user = None
|
|
|
|
if escalate_to_id:
|
|
try:
|
|
escalate_to_staff = Staff.objects.get(id=escalate_to_id, status="active")
|
|
if escalate_to_staff.user and escalate_to_staff.user.is_active:
|
|
escalate_to_user = escalate_to_staff.user
|
|
except Staff.DoesNotExist:
|
|
pass
|
|
|
|
if not escalate_to_user:
|
|
messages.error(request, _("Please select a valid person to escalate to."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
inquiry.escalated_at = timezone.now()
|
|
inquiry.save(update_fields=["escalated_at"])
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message=f"Inquiry escalated to {escalate_to_staff.get_full_name()}. Reason: {reason or 'N/A'}",
|
|
created_by=user,
|
|
)
|
|
|
|
email_subject = request.POST.get("email_subject", f"Inquiry Escalated - {inquiry.reference_number}")
|
|
email_body = request.POST.get("email_body", "")
|
|
|
|
if escalate_to_user.email:
|
|
try:
|
|
NotificationService.send_email(
|
|
email=escalate_to_user.email,
|
|
subject=email_subject,
|
|
message=email_body,
|
|
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;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Inquiry Escalated - {inquiry.reference_number}</h2>
|
|
<p style="margin: 0 0 12px 0;">An inquiry has been escalated to you.</p>
|
|
<table style="width: 100%; border-collapse: collapse; margin: 0 0 12px 0;">
|
|
<tr><td style="padding: 4px 0; color: #6b7280; width: 120px;">Reference:</td><td style="padding: 4px 0;"><strong>{inquiry.reference_number}</strong></td></tr>
|
|
<tr><td style="padding: 4px 0; color: #6b7280;">Escalated by:</td><td style="padding: 4px 0;">{user.get_full_name()}</td></tr>
|
|
<tr><td style="padding: 4px 0; color: #6b7280;">Reason:</td><td style="padding: 4px 0;">{reason or 'N/A'}</td></tr>
|
|
</table>
|
|
<div style="background: #f9fafb; padding: 12px; border-radius: 6px; margin: 0 0 12px 0;">
|
|
<p style="margin: 0;">{email_body}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=inquiry,
|
|
metadata={
|
|
"notification_type": "inquiry_escalated",
|
|
"escalated_by": str(user.id),
|
|
"reason": reason,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send inquiry escalation email: {e}")
|
|
|
|
messages.success(request, _(f"Inquiry escalated to {escalate_to_staff.get_full_name()}."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_send_to(request, pk):
|
|
"""
|
|
Unified AJAX endpoint to send inquiry to either a person or department.
|
|
"""
|
|
from django.http import JsonResponse
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or (user.is_department_manager() and user.department_id == inquiry.handling_department_id)
|
|
or user.is_px_management() or user.is_px_employee()
|
|
or (user.is_champion() and user.department_id == inquiry.handling_department_id)
|
|
):
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("You don't have permission to send this inquiry.")),
|
|
}, status=403)
|
|
|
|
# Must be activated (in_progress) before it can be sent/assigned
|
|
if inquiry.status == "open":
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Activate this inquiry before sending it to a department.")),
|
|
}, status=400)
|
|
|
|
recipient_type = request.POST.get("recipient_type", "department")
|
|
note = request.POST.get("note", "").strip()
|
|
|
|
try:
|
|
if recipient_type == "person":
|
|
person_id = request.POST.get("person_id")
|
|
if not person_id:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Please select a person.")),
|
|
}, status=400)
|
|
|
|
try:
|
|
person = User.objects.get(id=person_id)
|
|
except User.DoesNotExist:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("User not found.")),
|
|
}, status=400)
|
|
|
|
# Assign inquiry to person
|
|
inquiry.assigned_to = person
|
|
inquiry.assigned_at = timezone.now()
|
|
inquiry.save(update_fields=["assigned_to", "assigned_at"])
|
|
|
|
# Send notification
|
|
if person.email:
|
|
NotificationService.send_email(
|
|
email=person.email,
|
|
subject=f"Inquiry Assigned - {inquiry.reference_number}",
|
|
message=f"You have been assigned to inquiry #{inquiry.reference_number}.",
|
|
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;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Inquiry Assigned</h2>
|
|
<p>You have been assigned to inquiry <strong>#{inquiry.reference_number}</strong>.</p>
|
|
<p><strong>Subject:</strong> {inquiry.subject or 'N/A'}</p>
|
|
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
|
|
<p><a href="https://{request.get_host()}/inquiries/{inquiry.pk}/">View Inquiry</a></p>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=inquiry,
|
|
)
|
|
|
|
message = f"Inquiry sent to {person.get_full_name()}."
|
|
|
|
else: # department
|
|
department_id = request.POST.get("department_id")
|
|
if not department_id:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Please select a department.")),
|
|
}, status=400)
|
|
|
|
try:
|
|
department = Department.objects.select_related("champion", "manager").get(pk=department_id, status="active")
|
|
except Department.DoesNotExist:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Department not found.")),
|
|
}, status=400)
|
|
|
|
# Auto-target the department's champion and manager (no contact-person picker)
|
|
from apps.organizations.department_contacts import get_champion_and_manager
|
|
from apps.notifications.services import NotificationService
|
|
|
|
targets = get_champion_and_manager(department)
|
|
if not targets:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending.")),
|
|
}, status=400)
|
|
|
|
# Transfer to department
|
|
inquiry.outgoing_department = department
|
|
inquiry.transferred_at = timezone.now()
|
|
inquiry.transferred_by = user
|
|
inquiry.transferred_to_department = department
|
|
inquiry.transfer_count = (inquiry.transfer_count or 0) + 1
|
|
|
|
inquiry.sent_to_department = True
|
|
inquiry.sent_to_department_at = timezone.now()
|
|
|
|
sla_config = inquiry.get_sla_config()
|
|
if sla_config and sla_config.dept_response_hours:
|
|
from datetime import timedelta
|
|
inquiry.dept_response_sla_due_at = timezone.now() + timedelta(hours=sla_config.dept_response_hours)
|
|
inquiry.dept_response_is_overdue = False
|
|
inquiry.dept_response_reminder_sent_at = None
|
|
inquiry.dept_response_second_reminder_sent_at = None
|
|
inquiry.dept_response_escalated_at = None
|
|
|
|
# Notify champion + manager (email + SMS) with the inquiry link
|
|
notified = []
|
|
link = f"https://{request.get_host()}/inquiries/{inquiry.pk}/"
|
|
for target in targets:
|
|
display_name = (
|
|
target["staff"].get_full_name() if target.get("staff")
|
|
else (target["user"].get_full_name() if target.get("user") else target["label"])
|
|
)
|
|
if target.get("email"):
|
|
try:
|
|
NotificationService.send_email(
|
|
email=target["email"],
|
|
subject=f"Inquiry Sent to Department - {inquiry.reference_number}",
|
|
message=f"Inquiry #{inquiry.reference_number} has been sent to your department ({department.name}).\n\n{link}",
|
|
related_object=inquiry,
|
|
)
|
|
except Exception:
|
|
pass
|
|
if target.get("phone"):
|
|
try:
|
|
NotificationService.send_sms(
|
|
target["phone"],
|
|
f"PX360: Inquiry #{inquiry.reference_number} sent to {department.name}. Review: {link}",
|
|
related_object=inquiry,
|
|
)
|
|
except Exception:
|
|
pass
|
|
notified.append(f"{display_name} ({target['label']})")
|
|
|
|
message = f"Inquiry sent to {department.get_localized_name()} — {', '.join(notified)}."
|
|
|
|
# Set contact_status if open
|
|
if inquiry.status in ("open",):
|
|
inquiry.contact_status = "contacted"
|
|
inquiry.contact_status_at = timezone.now()
|
|
inquiry.contact_status_by = user
|
|
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="transferred_to_department" if recipient_type == "department" else "assigned",
|
|
message=note or f"Inquiry sent to {recipient_type}",
|
|
created_by=user,
|
|
)
|
|
|
|
return JsonResponse({
|
|
"success": True,
|
|
"message": message,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in inquiry_send_to: {str(e)}")
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("An error occurred while sending the inquiry.")),
|
|
}, status=500)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def inquiry_department_response(request, pk):
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or user.is_px_management()
|
|
or user.is_px_employee()
|
|
or (user.is_champion() and user.department_id == inquiry.handling_department_id)
|
|
or (user.is_department_manager() and user.department_id == inquiry.handling_department_id)
|
|
):
|
|
messages.error(request, "You don't have permission to respond to this inquiry.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
if request.method == "POST":
|
|
response_en = request.POST.get("response_en", "").strip()
|
|
response_ar = request.POST.get("response_ar", "").strip()
|
|
response = response_en or response_ar
|
|
|
|
if not response:
|
|
messages.error(request, "Please enter a response in at least one language.")
|
|
return redirect("inquiries:inquiry_department_response", pk=pk)
|
|
|
|
inquiry.department_response_en = response_en
|
|
inquiry.department_response_ar = response_ar
|
|
inquiry.department_responded_at = timezone.now()
|
|
inquiry.department_responded_by = request.user
|
|
inquiry.dept_response_is_overdue = False
|
|
inquiry.save()
|
|
|
|
try:
|
|
from apps.core.ai_service import AIService
|
|
import json
|
|
|
|
prompt = f"""You are an AI assistant for a hospital patient experience system. Summarize the following department response to a patient inquiry in 2-3 concise sentences.
|
|
|
|
Inquiry subject: {inquiry.subject}
|
|
Inquiry message: {inquiry.message[:500]}
|
|
Department response: {response[:500]}
|
|
|
|
Generate a JSON response with:
|
|
- "summary_en": A brief summary of the department's response in English (2-3 sentences)
|
|
- "summary_ar": The same summary translated to Modern Standard Arabic"""
|
|
|
|
result = AIService.chat_completion(
|
|
prompt=prompt,
|
|
response_format="json_object",
|
|
)
|
|
parsed = json.loads(result)
|
|
inquiry.department_response_summary_en = parsed.get("summary_en", "")
|
|
inquiry.department_response_summary_ar = parsed.get("summary_ar", "")
|
|
inquiry.save(update_fields=["department_response_summary_en", "department_response_summary_ar"])
|
|
except Exception as e:
|
|
logger.warning(f"AI summary of department response failed: {e}")
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="department_response",
|
|
message=f"Department response submitted by {user.get_full_name()}",
|
|
created_by=user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_department_response",
|
|
description=f"Department response submitted for inquiry: {inquiry.subject}",
|
|
user=user,
|
|
content_object=inquiry,
|
|
)
|
|
|
|
messages.success(request, "Department response submitted successfully.")
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
from django.http import JsonResponse
|
|
redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) if user.department else reverse("inquiries:inquiry_detail", kwargs={"pk": pk})
|
|
return JsonResponse({"success": True, "redirect_url": redirect_url})
|
|
if user.department:
|
|
return redirect("organizations:department_detail", pk=user.department.pk)
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
context = {
|
|
"inquiry": inquiry,
|
|
}
|
|
return render(request, "complaints/inquiry_department_response.html", context)
|
|
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_send_dept_response_reminder(request, pk):
|
|
from .models import Inquiry, InquiryUpdate
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
messages.error(request, "You don't have permission to send reminders.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
if inquiry.department_responded_at:
|
|
messages.warning(request, "Department has already responded.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
dept = inquiry.outgoing_department or inquiry.transferred_to_department
|
|
if not dept:
|
|
messages.error(request, "No department assigned.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
reminder_type = request.POST.get("reminder_type", "first")
|
|
|
|
if reminder_type == "first" and inquiry.dept_response_reminder_sent_at:
|
|
messages.warning(request, "First reminder already sent.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
if reminder_type == "second" and inquiry.dept_response_second_reminder_sent_at:
|
|
messages.warning(request, "Second reminder already sent.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
if reminder_type == "second" and not inquiry.dept_response_reminder_sent_at:
|
|
messages.warning(request, "Please send the first reminder before the second.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
try:
|
|
from apps.organizations.department_contacts import get_champion_and_manager
|
|
|
|
targets = get_champion_and_manager(dept)
|
|
if not targets:
|
|
messages.error(request, f"No champion or manager assigned to {dept.name}.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
recipients = []
|
|
for target in targets:
|
|
email = target.get("email") or ""
|
|
if email:
|
|
recipients.append({"email": email, "name": target.get("display_name", "")})
|
|
|
|
if reminder_type == "first":
|
|
inquiry.dept_response_reminder_sent_at = timezone.now()
|
|
else:
|
|
inquiry.dept_response_second_reminder_sent_at = timezone.now()
|
|
inquiry.save()
|
|
|
|
from .tasks import send_inquiry_reminder_email
|
|
send_inquiry_reminder_email.delay(str(inquiry.id), reminder_type, recipients)
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message=f"Manual {reminder_type} reminder sent by {user.get_full_name()}",
|
|
created_by=user,
|
|
)
|
|
|
|
messages.success(request, f"Reminder sent to {len(recipients)} recipient(s).")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send reminder: {e}")
|
|
messages.error(request, "Failed to send reminder.")
|
|
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
def inquiry_export_incoming(request):
|
|
"""Export incoming inquiries report."""
|
|
from .utils import export_inquiries_report
|
|
|
|
year = request.GET.get("year")
|
|
month = request.GET.get("month")
|
|
if not year or not month:
|
|
messages.error(request, "Year and month are required.")
|
|
return redirect("inquiries:inquiry_list")
|
|
|
|
queryset = Inquiry.objects.filter(created_at__year=int(year), created_at__month=int(month), is_outgoing=False)
|
|
if request.user.is_hospital_admin() and request.user.hospital:
|
|
queryset = queryset.filter(hospital=request.user.hospital)
|
|
elif request.user.is_px_admin() and request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
return export_inquiries_report(queryset, int(year), int(month), is_outgoing=False)
|
|
|
|
|
|
@login_required
|
|
def inquiry_export_outgoing(request):
|
|
"""Export outgoing inquiries report."""
|
|
from .utils import export_inquiries_report
|
|
|
|
year = request.GET.get("year")
|
|
month = request.GET.get("month")
|
|
if not year or not month:
|
|
messages.error(request, "Year and month are required.")
|
|
return redirect("inquiries:inquiry_list")
|
|
|
|
queryset = Inquiry.objects.filter(created_at__year=int(year), created_at__month=int(month), is_outgoing=True)
|
|
if request.user.is_hospital_admin() and request.user.hospital:
|
|
queryset = queryset.filter(hospital=request.user.hospital)
|
|
elif request.user.is_px_admin() and request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
return export_inquiries_report(queryset, int(year), int(month), is_outgoing=True)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_update_contact_stage(request, pk):
|
|
"""Update a contact tracking stage on the inquiry."""
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin() or inquiry.assigned_to == user or user.is_px_management() or user.is_px_employee()):
|
|
messages.error(request, _("You don't have permission to update contact tracking."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
stage = request.POST.get("stage")
|
|
import datetime as _dt
|
|
|
|
if stage == "contacted_nr":
|
|
date_str = request.POST.get("contacted_nr_date")
|
|
time_str = request.POST.get("contacted_nr_time")
|
|
duration_str = request.POST.get("contacted_nr_duration")
|
|
if date_str:
|
|
inquiry.contacted_nr_at = _dt.datetime.strptime(date_str, "%Y-%m-%d")
|
|
if time_str:
|
|
inquiry.contacted_nr_time = _dt.datetime.strptime(time_str, "%H:%M").time()
|
|
if duration_str:
|
|
parts = duration_str.split(":")
|
|
inquiry.contacted_nr_duration = _dt.timedelta(
|
|
hours=int(parts[0]), minutes=int(parts[1] if len(parts) > 1 else 0),
|
|
seconds=int(parts[2] if len(parts) > 2 else 0),
|
|
)
|
|
inquiry.contacted_nr_by = user
|
|
if inquiry.status == "open":
|
|
inquiry.contact_status = "contacted_no_response"
|
|
inquiry.contact_status_at = timezone.now()
|
|
inquiry.contact_status_by = user
|
|
|
|
elif stage == "under_process":
|
|
date_str = request.POST.get("under_process_date")
|
|
time_str = request.POST.get("under_process_time")
|
|
duration_str = request.POST.get("under_process_duration")
|
|
if date_str:
|
|
inquiry.under_process_at = _dt.datetime.strptime(date_str, "%Y-%m-%d")
|
|
if time_str:
|
|
inquiry.under_process_time = _dt.datetime.strptime(time_str, "%H:%M").time()
|
|
if duration_str:
|
|
parts = duration_str.split(":")
|
|
inquiry.under_process_duration = _dt.timedelta(
|
|
hours=int(parts[0]), minutes=int(parts[1] if len(parts) > 1 else 0),
|
|
seconds=int(parts[2] if len(parts) > 2 else 0),
|
|
)
|
|
inquiry.under_process_by = user
|
|
if inquiry.status == "open":
|
|
inquiry.status = "in_progress"
|
|
|
|
elif stage == "contacted":
|
|
date_str = request.POST.get("contacted_date")
|
|
time_str = request.POST.get("contacted_time")
|
|
duration_str = request.POST.get("contacted_duration")
|
|
if date_str:
|
|
inquiry.contacted_at = _dt.datetime.strptime(date_str, "%Y-%m-%d")
|
|
if time_str:
|
|
inquiry.contacted_time = _dt.datetime.strptime(time_str, "%H:%M").time()
|
|
if duration_str:
|
|
parts = duration_str.split(":")
|
|
inquiry.contacted_duration = _dt.timedelta(
|
|
hours=int(parts[0]), minutes=int(parts[1] if len(parts) > 1 else 0),
|
|
seconds=int(parts[2] if len(parts) > 2 else 0),
|
|
)
|
|
inquiry.contacted_by = user
|
|
if inquiry.status in ("open", "in_progress"):
|
|
inquiry.contact_status = "contacted"
|
|
inquiry.contact_status_at = timezone.now()
|
|
inquiry.contact_status_by = user
|
|
|
|
elif stage == "notes":
|
|
inquiry.staff_notes = request.POST.get("staff_notes", inquiry.staff_notes)
|
|
if user.is_px_admin() or user.is_hospital_admin():
|
|
inquiry.supervisor_notes = request.POST.get("supervisor_notes", inquiry.supervisor_notes)
|
|
|
|
elif stage == "timeline_sla":
|
|
inquiry.timeline_sla = request.POST.get("timeline_sla", inquiry.timeline_sla)
|
|
|
|
inquiry.save()
|
|
|
|
stage_labels = {
|
|
"contacted_nr": "Contacted No Response",
|
|
"under_process": "Under Process",
|
|
"contacted": "Contacted",
|
|
"notes": "Notes",
|
|
"timeline_sla": "Timeline SLA",
|
|
}
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message=f"Contact tracking updated: {stage_labels.get(stage, stage)}",
|
|
created_by=user,
|
|
)
|
|
messages.success(request, _("Contact tracking updated."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
def complaints_analytics(request):
|
|
"""
|
|
Complaints analytics dashboard.
|
|
"""
|
|
from .analytics import ComplaintAnalytics
|
|
|
|
user = request.user
|
|
hospital = None
|
|
|
|
# Apply RBAC
|
|
if user.is_px_admin() and request.tenant_hospital:
|
|
hospital = request.tenant_hospital
|
|
elif not user.is_px_admin() and user.hospital:
|
|
hospital = user.hospital
|
|
|
|
# Get date range from request
|
|
date_range = int(request.GET.get("date_range", 30))
|
|
|
|
# Get analytics data
|
|
dashboard_summary = ComplaintAnalytics.get_dashboard_summary(hospital)
|
|
trends = ComplaintAnalytics.get_complaint_trends(hospital, date_range)
|
|
sla_compliance = ComplaintAnalytics.get_sla_compliance(hospital, date_range)
|
|
resolution_rate = ComplaintAnalytics.get_resolution_rate(hospital, date_range)
|
|
top_categories = ComplaintAnalytics.get_top_categories(hospital, date_range)
|
|
overdue_complaints = ComplaintAnalytics.get_overdue_complaints(hospital)
|
|
|
|
context = {
|
|
"dashboard_summary": dashboard_summary,
|
|
"trends": trends,
|
|
"sla_compliance": sla_compliance,
|
|
"resolution_rate": resolution_rate,
|
|
"top_categories": top_categories,
|
|
"overdue_complaints": overdue_complaints,
|
|
"date_range": date_range,
|
|
}
|
|
|
|
return render(request, "complaints/analytics.html", context)
|
|
|
|
|
|
# ============================================================================
|
|
# PUBLIC COMPLAINT FORM (No Authentication Required)
|
|
# ============================================================================
|
|
|
|
|
|
def public_complaint_submit(request):
|
|
"""
|
|
Public complaint submission form (accessible without login).
|
|
Handles both GET (show form) and POST (submit complaint).
|
|
|
|
Updated to match form structure:
|
|
- Location hierarchy: Location, Main Section, Subsection (3-level dropdowns)
|
|
- Complainant information: name, email, mobile, relation to patient
|
|
- Patient information: name, national ID, incident date
|
|
- Staff reference: staff name
|
|
- Complaint details and expected result
|
|
"""
|
|
if request.method == "POST":
|
|
try:
|
|
# Get form data from public complaint form
|
|
complainant_name = request.POST.get("complainant_name")
|
|
email = request.POST.get("email", "")
|
|
mobile_number = request.POST.get("mobile_number")
|
|
relation_to_patient = request.POST.get("relation_to_patient", "")
|
|
hospital_id = request.POST.get("hospital")
|
|
category = request.POST.get("category", "")
|
|
location_type = request.POST.get("location_type", "")
|
|
department_id = request.POST.get("department")
|
|
section_id = request.POST.get("section")
|
|
area_id = request.POST.get("area")
|
|
patient_name = request.POST.get("patient_name", "")
|
|
national_id = request.POST.get("national_id", "")
|
|
incident_date = request.POST.get("incident_date")
|
|
staff_name = request.POST.get("staff_name", "")
|
|
complaint_details = request.POST.get("complaint_details")
|
|
expected_result = request.POST.get("expected_result", "")
|
|
|
|
# Validate required fields
|
|
errors = []
|
|
if not complainant_name:
|
|
errors.append(_("Complainant name is required"))
|
|
if not mobile_number:
|
|
errors.append(_("Mobile number is required"))
|
|
if not hospital_id:
|
|
errors.append(_("Hospital is required"))
|
|
if not location_type:
|
|
errors.append(_("Location type is required"))
|
|
if not department_id:
|
|
errors.append(_("Department is required"))
|
|
if not complaint_details:
|
|
errors.append(_("Complaint details are required"))
|
|
if incident_date:
|
|
from datetime import datetime as dt, date
|
|
try:
|
|
parsed_date = dt.strptime(incident_date, "%Y-%m-%d").date()
|
|
if parsed_date > date.today():
|
|
errors.append(_("Incident date cannot be in the future"))
|
|
except (ValueError, TypeError):
|
|
errors.append(_("Invalid incident date format"))
|
|
|
|
if errors:
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "errors": errors}, status=400)
|
|
else:
|
|
messages.error(request, _("Please fill in all required fields."))
|
|
return render(
|
|
request,
|
|
"complaints/public_complaint_form.html",
|
|
{
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
},
|
|
)
|
|
|
|
# Get hospital
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
|
|
from apps.organizations.models import Area, Department, Section, OrgSubSection
|
|
|
|
department = Department.objects.get(id=department_id)
|
|
section = Section.objects.filter(id=section_id).first() if section_id else None
|
|
area = Area.objects.filter(id=area_id).first() if area_id else None
|
|
|
|
# Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN)
|
|
|
|
# Create complaint with location hierarchy and all form fields.
|
|
# Patient linking is backgrounded (local DB first, then HIS) so a slow/unreachable
|
|
# HIS never blocks the public submission.
|
|
complaint = Complaint.objects.create(
|
|
patient=None,
|
|
hospital=hospital,
|
|
department=department,
|
|
section=section,
|
|
area=area,
|
|
location_type=location_type if location_type else "",
|
|
title="Complaint",
|
|
description=complaint_details,
|
|
severity="medium",
|
|
priority="medium",
|
|
status="open",
|
|
complaint_source_type=ComplaintSourceType.INTERNAL, source=PXSource.objects.filter(name_en="Public Form").first(),
|
|
contact_name=complainant_name,
|
|
contact_phone=mobile_number,
|
|
contact_email=email,
|
|
relation_to_patient=relation_to_patient,
|
|
patient_name=patient_name,
|
|
national_id=national_id,
|
|
incident_date=parsed_date if incident_date else None,
|
|
staff_name=staff_name,
|
|
expected_result=expected_result,
|
|
)
|
|
reference_number = complaint.reference_number
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message="Complaint submitted via public form. AI analysis running in background.",
|
|
)
|
|
|
|
# Trigger AI analysis + patient linking in the background using Celery
|
|
from .tasks import analyze_complaint_with_ai, link_complaint_patient, notify_staff_new_item
|
|
|
|
analyze_complaint_with_ai.delay(str(complaint.id))
|
|
notify_staff_new_item.delay("complaint", str(complaint.id))
|
|
link_complaint_patient.delay(str(complaint.id), national_id, mobile_number)
|
|
|
|
# If form was submitted via AJAX, return JSON
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"reference_number": reference_number,
|
|
"message": "Complaint submitted successfully. AI has analyzed and classified your complaint.",
|
|
}
|
|
)
|
|
|
|
# Otherwise, redirect to success page
|
|
return redirect("complaints:public_complaint_success", reference=reference_number)
|
|
|
|
except Hospital.DoesNotExist:
|
|
error_msg = _("Selected hospital not found.")
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "message": error_msg}, status=400)
|
|
messages.error(request, error_msg)
|
|
return render(
|
|
request,
|
|
"complaints/public_complaint_form.html",
|
|
{
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
},
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
# If AJAX, return error JSON
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "message": str(e)}, status=400)
|
|
|
|
# Otherwise, show error message
|
|
return render(
|
|
request,
|
|
"complaints/public_complaint_form.html",
|
|
{"hospitals": Hospital.objects.filter(status="active").order_by("name"), "error": str(e)},
|
|
)
|
|
|
|
# GET request - show form
|
|
return render(
|
|
request,
|
|
"complaints/public_complaint_form.html",
|
|
{
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
},
|
|
)
|
|
|
|
|
|
def public_complaint_pdf(request, reference_number):
|
|
"""Serve the latest PDF report for a complaint (no auth — scanned from QR code).
|
|
|
|
Finds the most recent ComplaintPdfSummary for the complaint and serves the
|
|
saved PDF file inline so the browser displays it directly.
|
|
"""
|
|
from django.http import Http404, HttpResponse
|
|
|
|
complaint = get_object_or_404(Complaint, reference_number__iexact=reference_number)
|
|
summary = complaint.pdf_summaries.order_by("-created_at").first()
|
|
if not summary or not summary.file:
|
|
raise Http404("No PDF report available for this complaint.")
|
|
response = HttpResponse(summary.file, content_type="application/pdf")
|
|
response["Content-Disposition"] = f'inline; filename="{complaint.reference_number}.pdf"'
|
|
return response
|
|
|
|
|
|
def public_complaint_track(request):
|
|
"""
|
|
Public complaint tracking page.
|
|
|
|
Allows complainants to check the status of their complaint using the reference number.
|
|
No authentication required.
|
|
|
|
Features:
|
|
- Form to enter reference number
|
|
- Display complaint status when found
|
|
- Show basic information (status, category, submission date, last update)
|
|
- Timeline of public updates (without exposing internal notes)
|
|
- SLA deadline information
|
|
- Patient satisfaction submission (locks the field once submitted).
|
|
The picker is available for 5 days after resolution/closure, matching the
|
|
unified tracker expiry in core/views.py.
|
|
"""
|
|
from datetime import timedelta
|
|
|
|
from django.utils import timezone
|
|
|
|
complaint = None
|
|
error_message = None
|
|
info_message = None
|
|
satisfaction_just_submitted = False
|
|
reference_number = request.GET.get("reference", "").strip()
|
|
tracking_expired = False
|
|
|
|
def _compute_tracking_expired(comp):
|
|
if not comp:
|
|
return False
|
|
expiry_base = comp.resolved_at or comp.closed_at
|
|
if not expiry_base:
|
|
return False
|
|
return timezone.now() > expiry_base + timedelta(days=5)
|
|
|
|
if request.method == "POST":
|
|
reference_number = request.POST.get("reference_number", "").strip()
|
|
|
|
if not reference_number:
|
|
error_message = _("Please enter a reference number.")
|
|
else:
|
|
# Try to find complaint by reference number
|
|
try:
|
|
complaint = (
|
|
Complaint.objects.select_related("hospital", "department")
|
|
.prefetch_related("updates")
|
|
.get(reference_number__iexact=reference_number)
|
|
)
|
|
|
|
# Check overdue status
|
|
complaint.check_overdue()
|
|
|
|
except Complaint.DoesNotExist:
|
|
error_message = _("No complaint found with this reference number. Please check and try again.")
|
|
|
|
tracking_expired = _compute_tracking_expired(complaint)
|
|
|
|
# Handle patient satisfaction submission (separate from reference search)
|
|
satisfaction_value = request.POST.get("satisfaction", "").strip()
|
|
if satisfaction_value and complaint is not None:
|
|
valid_choices = ["satisfied", "neutral", "dissatisfied"]
|
|
if satisfaction_value not in valid_choices:
|
|
error_message = _("Invalid satisfaction value.")
|
|
elif complaint.status not in ("resolved", "closed"):
|
|
error_message = _("Satisfaction can only be submitted after the complaint is resolved.")
|
|
elif tracking_expired:
|
|
error_message = _("This tracking link has expired. Satisfaction feedback can no longer be submitted.")
|
|
elif complaint.satisfaction_locked_by_patient:
|
|
info_message = _("You have already submitted your feedback. Thank you!")
|
|
else:
|
|
complaint.satisfaction = satisfaction_value
|
|
complaint.satisfaction_set_at = timezone.now()
|
|
complaint.satisfaction_locked_by_patient = True
|
|
complaint.satisfaction_locked_at = timezone.now()
|
|
complaint.save(
|
|
update_fields=[
|
|
"satisfaction",
|
|
"satisfaction_set_at",
|
|
"satisfaction_locked_by_patient",
|
|
"satisfaction_locked_at",
|
|
"updated_at",
|
|
]
|
|
)
|
|
satisfaction_just_submitted = True
|
|
|
|
elif reference_number:
|
|
# GET request with reference parameter
|
|
try:
|
|
complaint = (
|
|
Complaint.objects.select_related("hospital", "department")
|
|
.prefetch_related("updates")
|
|
.get(reference_number__iexact=reference_number)
|
|
)
|
|
|
|
# Check overdue status
|
|
complaint.check_overdue()
|
|
|
|
except Complaint.DoesNotExist:
|
|
error_message = _("No complaint found with this reference number. Please check and try again.")
|
|
|
|
tracking_expired = _compute_tracking_expired(complaint)
|
|
|
|
public_status = None
|
|
public_updates = []
|
|
if complaint:
|
|
public_status = complaint.public_status
|
|
|
|
public_updates = list(
|
|
complaint.updates.filter(update_type__in=["status_change", "resolution"]).order_by(
|
|
"-created_at"
|
|
)
|
|
)
|
|
|
|
_status_map = {
|
|
"open": str(_("Received")),
|
|
"in_progress": str(_("In Progress")),
|
|
"partially_resolved": str(_("In Progress")),
|
|
"resolved": str(_("Resolved")),
|
|
"closed": str(_("Closed")),
|
|
"cancelled": str(_("Cancelled")),
|
|
}
|
|
for update in public_updates:
|
|
if update.message:
|
|
for internal, public_label in _status_map.items():
|
|
update.message = update.message.replace(internal, public_label)
|
|
if hasattr(update, "old_status") and update.old_status:
|
|
update.old_status = _status_map.get(update.old_status, update.old_status)
|
|
if hasattr(update, "new_status") and update.new_status:
|
|
update.new_status = _status_map.get(update.new_status, update.new_status)
|
|
|
|
context = {
|
|
"complaint": complaint,
|
|
"public_status": public_status,
|
|
"public_updates": public_updates,
|
|
"error_message": error_message,
|
|
"info_message": info_message,
|
|
"satisfaction_just_submitted": satisfaction_just_submitted,
|
|
"tracking_expired": tracking_expired,
|
|
"reference_number": reference_number,
|
|
}
|
|
|
|
return render(request, "complaints/public_complaint_track.html", context)
|
|
|
|
|
|
def public_complaint_success(request, reference):
|
|
"""
|
|
Success page after public complaint submission.
|
|
"""
|
|
return render(request, "complaints/public_complaint_success.html", {"reference_number": reference})
|
|
|
|
|
|
def public_inquiry_submit(request):
|
|
"""
|
|
Public inquiry submission form (accessible without login).
|
|
Allows patients/public to submit general inquiries without authentication.
|
|
"""
|
|
if request.method == "POST":
|
|
try:
|
|
name = request.POST.get("name")
|
|
email = request.POST.get("email")
|
|
phone = request.POST.get("phone")
|
|
national_id = request.POST.get("national_id", "")
|
|
hospital_id = request.POST.get("hospital")
|
|
location_type = request.POST.get("location_type", "")
|
|
department_id = request.POST.get("department", "")
|
|
section_id = request.POST.get("section", "")
|
|
category = request.POST.get("category", "general")
|
|
subject = request.POST.get("subject")
|
|
message = request.POST.get("message")
|
|
|
|
errors = []
|
|
if not name:
|
|
errors.append(_("Name is required"))
|
|
if not phone:
|
|
errors.append(_("Phone number is required"))
|
|
if not hospital_id:
|
|
errors.append(_("Hospital is required"))
|
|
if not subject:
|
|
errors.append(_("Subject is required"))
|
|
if not message:
|
|
errors.append(_("Message is required"))
|
|
|
|
if errors:
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "errors": errors}, status=400)
|
|
else:
|
|
messages.error(request, _("Please fill in all required fields."))
|
|
return render(
|
|
request,
|
|
"complaints/public_inquiry_form.html",
|
|
{
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
"categories": [
|
|
("appointment", "Appointment"),
|
|
("billing", "Billing"),
|
|
("medical_records", "Medical Records"),
|
|
("general", "General Information"),
|
|
("other", "Other"),
|
|
],
|
|
},
|
|
)
|
|
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
|
|
from apps.organizations.models import Department, Section
|
|
department = Department.objects.filter(id=department_id).first() if department_id else None
|
|
section = Section.objects.filter(id=section_id).first() if section_id else None
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
# Reference number generated by Inquiry.save() (unified INQ-YYYYMM-HOSP-NNNN)
|
|
# Patient linking is backgrounded (local DB first, then HIS) so a slow HIS
|
|
# never blocks the public inquiry submission.
|
|
inquiry = Inquiry.objects.create(
|
|
patient=None,
|
|
hospital=hospital,
|
|
department=department,
|
|
section=section,
|
|
location_type=location_type if location_type else "",
|
|
subject=subject,
|
|
message=message,
|
|
category=category,
|
|
contact_name=name,
|
|
contact_phone=phone,
|
|
contact_email=email,
|
|
national_id=national_id,
|
|
status="open",
|
|
is_outgoing=False,
|
|
)
|
|
reference_number = inquiry.reference_number
|
|
|
|
from apps.complaints.tasks import analyze_inquiry_with_ai, link_inquiry_patient, notify_staff_new_item
|
|
|
|
analyze_inquiry_with_ai.delay(str(inquiry.id))
|
|
notify_staff_new_item.delay("inquiry", str(inquiry.id))
|
|
link_inquiry_patient.delay(str(inquiry.id), phone)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_created_public",
|
|
description=f"Public inquiry submitted: {subject}",
|
|
content_object=inquiry,
|
|
metadata={"reference": reference_number, "source": "public_form"},
|
|
)
|
|
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"message": _("Inquiry submitted successfully!"),
|
|
"reference_number": reference_number,
|
|
}
|
|
)
|
|
|
|
return redirect("inquiries:public_inquiry_success", reference=reference_number)
|
|
|
|
except Hospital.DoesNotExist:
|
|
error_msg = _("Selected hospital not found.")
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "message": error_msg}, status=400)
|
|
messages.error(request, error_msg)
|
|
except Exception as e:
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "message": str(e)}, status=400)
|
|
messages.error(request, str(e))
|
|
|
|
return render(
|
|
request,
|
|
"complaints/public_inquiry_form.html",
|
|
{
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
"categories": [
|
|
("appointment", "Appointment"),
|
|
("billing", "Billing"),
|
|
("medical_records", "Medical Records"),
|
|
("general", "General Information"),
|
|
("other", "Other"),
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
def public_inquiry_success(request, reference):
|
|
"""
|
|
Success page after public inquiry submission.
|
|
"""
|
|
return render(request, "complaints/public_inquiry_success.html", {"reference_number": reference})
|
|
|
|
|
|
def public_inquiry_track(request):
|
|
"""
|
|
Public inquiry tracking page. Allows users to check their inquiry status
|
|
using the reference number received after submission.
|
|
"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = None
|
|
public_status = None
|
|
error_message = None
|
|
reference = request.GET.get("reference", "").strip() or request.POST.get("reference", "").strip()
|
|
|
|
if request.method == "POST":
|
|
reference = request.POST.get("reference_number", "").strip()
|
|
|
|
if not reference and request.GET.get("reference"):
|
|
reference = request.GET.get("reference").strip()
|
|
|
|
if reference:
|
|
inquiry = (
|
|
Inquiry.objects.filter(reference_number__iexact=reference)
|
|
.select_related("hospital", "department")
|
|
.first()
|
|
)
|
|
if inquiry:
|
|
status_map = {
|
|
"open": {"label": _("Received"), "slug": "received", "progress": 15, "css": "amber"},
|
|
"in_progress": {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
|
|
"contacted": {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
|
|
"contacted_no_response": {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
|
|
"resolved": {"label": _("Resolved"), "slug": "resolved", "progress": 100, "css": "emerald"},
|
|
"closed": {"label": _("Closed"), "slug": "closed", "progress": 100, "css": "slate"},
|
|
}
|
|
sm = status_map.get(inquiry.status, {"label": _("Received"), "slug": "received", "progress": 15, "css": "amber"})
|
|
public_status = {
|
|
"label": str(sm["label"]),
|
|
"slug": sm["slug"],
|
|
"progress": sm["progress"],
|
|
"css": sm["css"],
|
|
}
|
|
else:
|
|
error_message = _("No inquiry found with this reference number. Please check and try again.")
|
|
|
|
has_response = bool(inquiry and (inquiry.department_response_en or inquiry.department_response_ar))
|
|
response_en = inquiry.department_response_en if inquiry else ""
|
|
response_ar = inquiry.department_response_ar if inquiry else ""
|
|
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
if inquiry:
|
|
return JsonResponse({
|
|
"success": True,
|
|
"reference": inquiry.reference_number,
|
|
"status": inquiry.get_status_display(),
|
|
"subject": inquiry.subject,
|
|
"created_at": inquiry.created_at.strftime("%Y-%m-%d %H:%M"),
|
|
"has_response": has_response,
|
|
"response_en": response_en,
|
|
"response_ar": response_ar,
|
|
})
|
|
else:
|
|
return JsonResponse({"success": False, "error": "Inquiry not found"})
|
|
|
|
return render(request, "complaints/public_inquiry_track.html", {
|
|
"inquiry": inquiry,
|
|
"public_status": public_status,
|
|
"error_message": error_message,
|
|
"reference_number": reference,
|
|
"has_response": has_response,
|
|
"response_en": response_en,
|
|
"response_ar": response_ar,
|
|
})
|
|
|
|
|
|
def api_lookup_patient(request):
|
|
"""
|
|
AJAX endpoint to look up patient by national ID or phone.
|
|
No authentication required for public form.
|
|
"""
|
|
from apps.organizations.models import Patient
|
|
|
|
national_id = request.GET.get("national_id", "").strip()
|
|
phone = request.GET.get("phone", "").strip()
|
|
|
|
if not national_id and not phone:
|
|
return JsonResponse({"found": False, "error": "National ID or phone required"}, status=400)
|
|
|
|
patient = None
|
|
lookup_method = None
|
|
|
|
if national_id:
|
|
from apps.core.encryption import compute_national_id_hash
|
|
|
|
nid_hash = compute_national_id_hash(national_id)
|
|
patient = Patient.objects.filter(national_id_hash=nid_hash, status="active").first()
|
|
lookup_method = "national_id"
|
|
|
|
if not patient and phone:
|
|
patient = Patient.objects.filter(phone=phone, status="active").first()
|
|
lookup_method = "phone"
|
|
|
|
if patient:
|
|
return JsonResponse(
|
|
{
|
|
"found": True,
|
|
"id": str(patient.id),
|
|
"mrn": patient.mrn,
|
|
"name": patient.get_full_name(),
|
|
"phone": patient.phone or "",
|
|
"email": patient.email or "",
|
|
"lookup_method": lookup_method,
|
|
}
|
|
)
|
|
|
|
return JsonResponse({"found": False, "message": "Patient not found"})
|
|
|
|
|
|
def api_load_departments(request):
|
|
"""
|
|
AJAX endpoint to load departments for a hospital.
|
|
No authentication required for public form.
|
|
"""
|
|
hospital_id = request.GET.get("hospital_id")
|
|
|
|
if not hospital_id:
|
|
return JsonResponse({"departments": []})
|
|
|
|
departments = Department.objects.filter(hospital_id=hospital_id, status="active").values("id", "name")
|
|
|
|
return JsonResponse({"departments": list(departments)})
|
|
|
|
|
|
def api_load_categories(request):
|
|
"""
|
|
AJAX endpoint to load complaint categories for a hospital.
|
|
Shows hospital-specific categories first, then system-wide categories.
|
|
Returns both parent categories and their subcategories with parent_id.
|
|
Now includes level field for 4-level hierarchy support (Domain, Category, Subcategory, Classification).
|
|
No authentication required for public form.
|
|
|
|
Updated: Always returns system-wide categories even without hospital_id,
|
|
to support initial form loading.
|
|
"""
|
|
from .models import ComplaintCategory
|
|
|
|
hospital_id = request.GET.get("hospital_id")
|
|
|
|
# Build queryset - always include system-wide categories
|
|
if hospital_id:
|
|
# Return hospital-specific and system-wide categories
|
|
# Empty hospitals list = system-wide
|
|
categories_queryset = (
|
|
ComplaintCategory.objects.filter(Q(hospitals__id=hospital_id) | Q(hospitals__isnull=True), is_active=True)
|
|
.distinct()
|
|
.order_by("level", "order", "name_en")
|
|
)
|
|
else:
|
|
# Return all system-wide categories (empty hospitals list)
|
|
# This allows form to load domains on initial page load
|
|
categories_queryset = ComplaintCategory.objects.filter(Q(hospitals__isnull=True), is_active=True).order_by(
|
|
"level", "order", "name_en"
|
|
)
|
|
|
|
# Get all categories with parent_id, level, domain_type, and descriptions
|
|
categories = categories_queryset.values(
|
|
"id", "name_en", "name_ar", "code", "parent_id", "level", "domain_type", "description_en", "description_ar"
|
|
)
|
|
|
|
return JsonResponse({"categories": list(categories)})
|
|
|
|
|
|
# ============================================================================
|
|
# AJAX/API HELPERS (Authentication Required)
|
|
# ============================================================================
|
|
|
|
|
|
@login_required
|
|
def get_departments_by_hospital(request):
|
|
"""Get departments for a hospital (AJAX)"""
|
|
hospital_id = request.GET.get("hospital_id")
|
|
if not hospital_id:
|
|
return JsonResponse({"departments": []})
|
|
|
|
departments = Department.objects.filter(hospital_id=hospital_id, status="active").values("id", "name", "name_ar")
|
|
|
|
return JsonResponse({"departments": list(departments)})
|
|
|
|
|
|
@login_required
|
|
def get_staff_by_department(request):
|
|
"""Get staff for a department (AJAX)"""
|
|
department_id = request.GET.get("department_id")
|
|
if not department_id:
|
|
return JsonResponse({"staff": []})
|
|
|
|
staff_members = Staff.objects.filter(department_id=department_id, status="active").values(
|
|
"id", "first_name", "last_name", "staff_type", "job_title"
|
|
)
|
|
|
|
return JsonResponse({"staff": list(staff_members)})
|
|
|
|
|
|
@login_required
|
|
def search_patients(request):
|
|
"""Search patients by MRN or name (AJAX)"""
|
|
from apps.organizations.models import Patient
|
|
|
|
query = request.GET.get("q", "")
|
|
if len(query) < 2:
|
|
return JsonResponse({"patients": []})
|
|
|
|
patients = Patient.objects.filter(
|
|
Q(mrn__icontains=query)
|
|
| Q(first_name__icontains=query)
|
|
| Q(last_name__icontains=query)
|
|
| Q(national_id__icontains=query)
|
|
)[:10]
|
|
|
|
results = [
|
|
{
|
|
"id": str(p.id),
|
|
"mrn": p.mrn,
|
|
"name": p.get_full_name(),
|
|
"phone": p.phone,
|
|
"email": p.email,
|
|
}
|
|
for p in patients
|
|
]
|
|
|
|
return JsonResponse({"patients": results})
|
|
|
|
|
|
@px_admin_required
|
|
@login_required
|
|
def sla_management(request):
|
|
"""
|
|
PX Admin-only SLA management page.
|
|
|
|
Automatically uses the hospital from session (selected after login).
|
|
Provides a clean interface to manage complaint SLA configurations.
|
|
"""
|
|
from .models import ComplaintSLAConfig
|
|
from apps.organizations.models import Hospital
|
|
|
|
# Get hospital from session (set after login)
|
|
hospital_id = request.session.get("selected_hospital_id")
|
|
|
|
if not hospital_id:
|
|
messages.error(request, "Please select a hospital first.")
|
|
return redirect("core:select_hospital")
|
|
|
|
# Get the hospital
|
|
try:
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
except Hospital.DoesNotExist:
|
|
messages.error(request, "Selected hospital not found.")
|
|
return redirect("core:select_hospital")
|
|
|
|
# Get all SLA configs for this hospital
|
|
sla_configs = (
|
|
ComplaintSLAConfig.objects.filter(hospital=hospital, is_active=True)
|
|
.select_related("source")
|
|
.order_by("source", "severity", "priority")
|
|
)
|
|
|
|
# Group configs by type
|
|
source_based_configs = sla_configs.filter(source__isnull=False)
|
|
|
|
context = {
|
|
"hospital": hospital,
|
|
"source_based_configs": source_based_configs,
|
|
"total_configs": sla_configs.count(),
|
|
}
|
|
|
|
return render(request, "complaints/sla_management.html", context)
|
|
|
|
|
|
@px_admin_required
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def sla_management_create(request):
|
|
"""
|
|
PX Admin-only: Create new SLA configuration for selected hospital.
|
|
"""
|
|
from .models import ComplaintSLAConfig
|
|
from .forms import SLAConfigForm
|
|
from apps.organizations.models import Hospital
|
|
|
|
# Get hospital from session
|
|
hospital_id = request.session.get("selected_hospital_id")
|
|
|
|
if not hospital_id:
|
|
messages.error(request, "Please select a hospital first.")
|
|
return redirect("core:select_hospital")
|
|
|
|
try:
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
except Hospital.DoesNotExist:
|
|
messages.error(request, "Selected hospital not found.")
|
|
return redirect("core:select_hospital")
|
|
|
|
if request.method == "POST":
|
|
form = SLAConfigForm(request.POST, request=request)
|
|
|
|
if form.is_valid():
|
|
# Force hospital to session hospital
|
|
sla_config = form.save(commit=False)
|
|
sla_config.hospital = hospital
|
|
sla_config.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="sla_config_created",
|
|
description=f"SLA configuration created: {sla_config}",
|
|
user=request.user,
|
|
content_object=sla_config,
|
|
metadata={
|
|
"hospital": str(sla_config.hospital),
|
|
"severity": sla_config.severity,
|
|
"priority": sla_config.priority,
|
|
"sla_hours": sla_config.sla_hours,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "SLA configuration created successfully.")
|
|
return redirect("complaints:sla_management")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = SLAConfigForm(request=request)
|
|
# Pre-select hospital (hidden field)
|
|
form.fields["hospital"].initial = hospital.id
|
|
|
|
context = {
|
|
"form": form,
|
|
"hospital": hospital,
|
|
"title": "Create SLA Configuration",
|
|
"action": "Create",
|
|
}
|
|
|
|
return render(request, "complaints/sla_management_form.html", context)
|
|
|
|
|
|
@px_admin_required
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def sla_management_edit(request, pk):
|
|
"""
|
|
PX Admin-only: Edit SLA configuration for selected hospital.
|
|
"""
|
|
from .models import ComplaintSLAConfig
|
|
from .forms import SLAConfigForm
|
|
from apps.organizations.models import Hospital
|
|
|
|
# Get hospital from session
|
|
hospital_id = request.session.get("selected_hospital_id")
|
|
|
|
if not hospital_id:
|
|
messages.error(request, "Please select a hospital first.")
|
|
return redirect("core:select_hospital")
|
|
|
|
try:
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
except Hospital.DoesNotExist:
|
|
messages.error(request, "Selected hospital not found.")
|
|
return redirect("core:select_hospital")
|
|
|
|
sla_config = get_object_or_404(ComplaintSLAConfig, pk=pk)
|
|
|
|
if request.method == "POST":
|
|
form = SLAConfigForm(request.POST, request=request, instance=sla_config)
|
|
|
|
if form.is_valid():
|
|
sla_config = form.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="sla_config_updated",
|
|
description=f"SLA configuration updated: {sla_config}",
|
|
user=request.user,
|
|
content_object=sla_config,
|
|
metadata={
|
|
"hospital": str(sla_config.hospital),
|
|
"severity": sla_config.severity,
|
|
"priority": sla_config.priority,
|
|
"sla_hours": sla_config.sla_hours,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "SLA configuration updated successfully.")
|
|
return redirect("complaints:sla_management")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = SLAConfigForm(request=request, instance=sla_config)
|
|
|
|
context = {
|
|
"form": form,
|
|
"sla_config": sla_config,
|
|
"hospital": hospital,
|
|
"title": "Edit SLA Configuration",
|
|
"action": "Update",
|
|
}
|
|
|
|
return render(request, "complaints/sla_management_form.html", context)
|
|
|
|
|
|
@px_admin_required
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def sla_management_toggle(request, pk):
|
|
"""
|
|
PX Admin-only: Toggle SLA configuration active/inactive status.
|
|
"""
|
|
from .models import ComplaintSLAConfig
|
|
|
|
sla_config = get_object_or_404(ComplaintSLAConfig, pk=pk)
|
|
|
|
# Toggle status
|
|
sla_config.is_active = not sla_config.is_active
|
|
sla_config.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="sla_config_toggled",
|
|
description=f"SLA configuration {'activated' if sla_config.is_active else 'deactivated'}: {sla_config}",
|
|
user=request.user,
|
|
content_object=sla_config,
|
|
metadata={
|
|
"hospital": str(sla_config.hospital),
|
|
"is_active": sla_config.is_active,
|
|
},
|
|
)
|
|
|
|
status_text = "activated" if sla_config.is_active else "deactivated"
|
|
messages.success(request, f"SLA configuration {status_text} successfully.")
|
|
return redirect("complaints:sla_management")
|
|
|
|
|
|
@login_required
|
|
def complaint_threshold_list(request):
|
|
"""
|
|
Complaint Threshold list view with filters.
|
|
|
|
Allows Hospital Admins and PX Admins to manage complaint thresholds.
|
|
"""
|
|
from .models import ComplaintThreshold
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
messages.error(request, "You don't have permission to manage complaint thresholds.")
|
|
return redirect("accounts:settings")
|
|
|
|
# Base queryset
|
|
queryset = ComplaintThreshold.objects.select_related("hospital").all()
|
|
|
|
# Apply hospital filter
|
|
if not user.is_px_admin() and user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
|
|
# Apply filters from request
|
|
hospital_filter = request.GET.get("hospital")
|
|
if hospital_filter:
|
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
|
|
|
threshold_type_filter = request.GET.get("threshold_type")
|
|
if threshold_type_filter:
|
|
queryset = queryset.filter(threshold_type=threshold_type_filter)
|
|
|
|
is_active_filter = request.GET.get("is_active")
|
|
if is_active_filter:
|
|
queryset = queryset.filter(is_active=(is_active_filter == "true"))
|
|
|
|
# Ordering
|
|
order_by = request.GET.get("order_by", "hospital__name")
|
|
queryset = queryset.order_by(order_by)
|
|
|
|
# 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)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"thresholds": page_obj.object_list,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "complaints/complaint_threshold_list.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def complaint_threshold_create(request):
|
|
"""
|
|
Create new complaint threshold.
|
|
"""
|
|
from .models import ComplaintThreshold
|
|
from .forms import ComplaintThresholdForm
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
messages.error(request, "You don't have permission to create complaint thresholds.")
|
|
return redirect("accounts:settings")
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintThresholdForm(request.POST, request=request)
|
|
|
|
if form.is_valid():
|
|
threshold = form.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="complaint_threshold_created",
|
|
description=f"Complaint threshold created: {threshold}",
|
|
user=request.user,
|
|
content_object=threshold,
|
|
metadata={
|
|
"hospital": str(threshold.hospital),
|
|
"threshold_type": threshold.threshold_type,
|
|
"threshold_value": threshold.threshold_value,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "Complaint threshold created successfully.")
|
|
return redirect("complaints:complaint_threshold_list")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = ComplaintThresholdForm(request=request)
|
|
|
|
context = {
|
|
"form": form,
|
|
"title": "Create Complaint Threshold",
|
|
"action": "Create",
|
|
}
|
|
|
|
return render(request, "complaints/complaint_threshold_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def complaint_threshold_edit(request, pk):
|
|
"""
|
|
Edit existing complaint threshold.
|
|
"""
|
|
from .models import ComplaintThreshold
|
|
from .forms import ComplaintThresholdForm
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
messages.error(request, "You don't have permission to edit complaint thresholds.")
|
|
return redirect("accounts:settings")
|
|
|
|
threshold = get_object_or_404(ComplaintThreshold, pk=pk)
|
|
|
|
# Check if user can edit this threshold
|
|
if not user.is_px_admin() and threshold.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to edit this complaint threshold.")
|
|
return redirect("complaints:complaint_threshold_list")
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintThresholdForm(request.POST, request=request, instance=threshold)
|
|
|
|
if form.is_valid():
|
|
threshold = form.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="complaint_threshold_updated",
|
|
description=f"Complaint threshold updated: {threshold}",
|
|
user=request.user,
|
|
content_object=threshold,
|
|
metadata={
|
|
"hospital": str(threshold.hospital),
|
|
"threshold_type": threshold.threshold_type,
|
|
"threshold_value": threshold.threshold_value,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "Complaint threshold updated successfully.")
|
|
return redirect("complaints:complaint_threshold_list")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = ComplaintThresholdForm(request=request, instance=threshold)
|
|
|
|
context = {
|
|
"form": form,
|
|
"threshold": threshold,
|
|
"title": "Edit Complaint Threshold",
|
|
"action": "Update",
|
|
}
|
|
|
|
return render(request, "complaints/complaint_threshold_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_threshold_delete(request, pk):
|
|
"""
|
|
Delete complaint threshold.
|
|
"""
|
|
from .models import ComplaintThreshold
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
messages.error(request, "You don't have permission to delete complaint thresholds.")
|
|
return redirect("accounts:settings")
|
|
|
|
threshold = get_object_or_404(ComplaintThreshold, pk=pk)
|
|
|
|
# Check if user can delete this threshold
|
|
if not user.is_px_admin() and threshold.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to delete this complaint threshold.")
|
|
return redirect("complaints:complaint_threshold_list")
|
|
|
|
threshold.delete()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="complaint_threshold_deleted",
|
|
description=f"Complaint threshold deleted: {threshold}",
|
|
user=request.user,
|
|
metadata={
|
|
"hospital": str(threshold.hospital),
|
|
"threshold_type": threshold.threshold_type,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "Complaint threshold deleted successfully.")
|
|
return redirect("complaints:complaint_threshold_list")
|
|
|
|
|
|
# ============================================================================
|
|
# Involved Departments and Staff Views
|
|
# ============================================================================
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def confirm_ai_department_suggestion(request, complaint_pk):
|
|
"""
|
|
Confirm the AI-suggested department and add it as an involved department.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment
|
|
|
|
complaint = get_object_or_404(Complaint, pk=complaint_pk)
|
|
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
suggested_department = complaint.department
|
|
if not suggested_department:
|
|
messages.warning(request, _("No AI department suggestion found."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
already_involved = complaint.involved_departments.filter(department=suggested_department).exists()
|
|
if already_involved:
|
|
messages.info(request, _("This department is already involved."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
ComplaintInvolvedDepartment.objects.create(
|
|
complaint=complaint,
|
|
department=suggested_department,
|
|
role="primary",
|
|
is_primary=True,
|
|
added_by=user,
|
|
assigned_at=timezone.now(),
|
|
)
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"AI suggested department confirmed: {suggested_department.name} (Primary)",
|
|
created_by=user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="complaint_department_added",
|
|
description=f"AI department suggestion confirmed: {suggested_department.name} for complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=complaint,
|
|
metadata={
|
|
"department_id": str(suggested_department.pk),
|
|
"department_name": suggested_department.name,
|
|
"role": "primary",
|
|
"is_primary": True,
|
|
"source": "ai_suggestion",
|
|
},
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
_("Department '%(dept)s' added as Primary (AI suggestion confirmed).") % {"dept": suggested_department.name},
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def involved_department_add(request, complaint_pk):
|
|
"""
|
|
Add an involved department to a complaint.
|
|
|
|
Allows assigning multiple departments with different roles:
|
|
- Primary: Main responsible department
|
|
- Secondary/Supporting: Assisting departments
|
|
- Coordination: Only for coordination
|
|
- Investigating: Leading the investigation
|
|
"""
|
|
complaint = get_object_or_404(Complaint, pk=complaint_pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintInvolvedDepartmentForm(request.POST, complaint=complaint, user=user)
|
|
|
|
if form.is_valid():
|
|
involved_dept = form.save(commit=False)
|
|
involved_dept.complaint = complaint
|
|
involved_dept.added_by = user
|
|
|
|
# Set assignment timestamp if assigned
|
|
if involved_dept.assigned_to and not involved_dept.assigned_at:
|
|
involved_dept.assigned_at = timezone.now()
|
|
|
|
involved_dept.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Department added: {involved_dept.department.name} ({involved_dept.get_role_display()})",
|
|
created_by=user,
|
|
)
|
|
|
|
# Audit log
|
|
AuditService.log_event(
|
|
event_type="complaint_department_added",
|
|
description=f"Department {involved_dept.department.name} added to complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=complaint,
|
|
metadata={
|
|
"department_id": str(involved_dept.department.pk),
|
|
"department_name": involved_dept.department.name,
|
|
"role": involved_dept.role,
|
|
"is_primary": involved_dept.is_primary,
|
|
},
|
|
)
|
|
|
|
success_msg = _("Department '%(dept)s' added successfully as %(role)s.") % {
|
|
"dept": involved_dept.department.name,
|
|
"role": involved_dept.get_role_display(),
|
|
}
|
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
return JsonResponse({"success": True, "message": str(success_msg)})
|
|
messages.success(request, success_msg)
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
else:
|
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
return JsonResponse(
|
|
{"success": False, "errors": form.errors.get_json_data()},
|
|
status=400,
|
|
)
|
|
messages.error(request, _("Please correct the errors below."))
|
|
else:
|
|
form = ComplaintInvolvedDepartmentForm(complaint=complaint, user=user)
|
|
|
|
context = {
|
|
"form": form,
|
|
"complaint": complaint,
|
|
"title": _("Add Involved Department"),
|
|
}
|
|
|
|
return render(request, "complaints/involved_department_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def involved_department_edit(request, pk):
|
|
"""
|
|
Edit an involved department's details.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment
|
|
|
|
involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
|
|
complaint = involved_dept.complaint
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintInvolvedDepartmentForm(request.POST, instance=involved_dept, complaint=complaint, user=user)
|
|
|
|
if form.is_valid():
|
|
# Check if assignment is being changed
|
|
old_assigned_to = involved_dept.assigned_to
|
|
involved_dept = form.save(commit=False)
|
|
|
|
# Set assignment timestamp if newly assigned
|
|
if involved_dept.assigned_to and not old_assigned_to:
|
|
involved_dept.assigned_at = timezone.now()
|
|
|
|
involved_dept.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Department updated: {involved_dept.department.name} ({involved_dept.get_role_display()})",
|
|
created_by=user,
|
|
)
|
|
|
|
messages.success(request, _("Department involvement updated successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
else:
|
|
messages.error(request, _("Please correct the errors below."))
|
|
else:
|
|
form = ComplaintInvolvedDepartmentForm(instance=involved_dept, complaint=complaint, user=user)
|
|
|
|
context = {
|
|
"form": form,
|
|
"complaint": complaint,
|
|
"involved_dept": involved_dept,
|
|
"title": _("Edit Involved Department"),
|
|
}
|
|
|
|
return render(request, "complaints/involved_department_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def involved_department_remove(request, pk):
|
|
"""
|
|
Remove an involved department from a complaint.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment
|
|
|
|
involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
|
|
complaint = involved_dept.complaint
|
|
department_name = involved_dept.department.name
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
# If the user is removing the complaint's primary department involvement,
|
|
# record it so ensure_involved_records() does not auto-recreate it on the
|
|
# next detail-view render. Stored as the dept id; if complaint.department
|
|
# later changes to a different dept, auto-recreation resumes automatically.
|
|
if involved_dept.department_id == complaint.department_id and complaint.department_id is not None:
|
|
complaint.primary_dept_involved_removed_id = complaint.department_id
|
|
complaint.save(update_fields=["primary_dept_involved_removed"])
|
|
|
|
involved_dept.delete()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Department removed: {department_name}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Audit log
|
|
AuditService.log_event(
|
|
event_type="complaint_department_removed",
|
|
description=f"Department {department_name} removed from complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=complaint,
|
|
)
|
|
|
|
messages.success(request, _("Department removed successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def involved_department_response(request, pk):
|
|
"""
|
|
Submit a department's response to the complaint.
|
|
Supports both regular POST and AJAX requests.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment
|
|
from django.http import JsonResponse
|
|
|
|
involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
|
|
complaint = involved_dept.complaint
|
|
user = request.user
|
|
|
|
# Check permission - must be champion/manager of this department, assigned to it, or have complaint management rights
|
|
is_dept_champion = (
|
|
user.is_champion()
|
|
and user.department == involved_dept.department
|
|
)
|
|
is_dept_manager = (
|
|
user.is_department_manager()
|
|
and user.department == involved_dept.department
|
|
)
|
|
can_respond = (
|
|
is_dept_champion
|
|
or is_dept_manager
|
|
or involved_dept.assigned_to == user
|
|
or can_manage_complaint(user, complaint)
|
|
)
|
|
|
|
if not can_respond:
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("You don't have permission to submit a response for this department.")),
|
|
}, status=403)
|
|
messages.error(request, _("You don't have permission to submit a response for this department."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
response_notes = request.POST.get("response_notes", "").strip()
|
|
response_notes_en = request.POST.get("response_notes_en", "").strip()
|
|
response_notes_ar = request.POST.get("response_notes_ar", "").strip()
|
|
|
|
if not response_notes and not response_notes_en and not response_notes_ar:
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Please provide a valid response.")),
|
|
}, status=400)
|
|
messages.error(request, _("Please provide a valid response."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
involved_dept.response_notes = response_notes or response_notes_en or response_notes_ar
|
|
involved_dept.response_notes_en = response_notes_en
|
|
involved_dept.response_notes_ar = response_notes_ar
|
|
involved_dept.response_submitted = True
|
|
involved_dept.response_submitted_at = timezone.now()
|
|
involved_dept.acceptance_status = "acceptable"
|
|
involved_dept.accepted_at = timezone.now()
|
|
involved_dept.save()
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Response submitted by {involved_dept.department.name}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Notify complainant that department has responded
|
|
try:
|
|
from apps.notifications.services import NotificationService
|
|
from apps.core.utils import build_public_track_url
|
|
|
|
track_url = build_public_track_url("complaint", complaint.reference_number)
|
|
if complaint.contact_phone:
|
|
NotificationService.send_sms(
|
|
phone=complaint.contact_phone,
|
|
message=f"PX360: Your complaint {complaint.reference_number} has been responded to. View: {track_url}",
|
|
related_object=complaint,
|
|
)
|
|
if complaint.contact_email:
|
|
NotificationService.send_email(
|
|
email=complaint.contact_email,
|
|
subject=f"PX360: Response to Your Complaint {complaint.reference_number}",
|
|
message=f"Your complaint has been responded to.\n\nView: {track_url}",
|
|
related_object=complaint,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send complainant notification: {e}")
|
|
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) if user.department else reverse("complaints:complaint_detail", kwargs={"pk": complaint.pk})
|
|
return JsonResponse({
|
|
"success": True,
|
|
"message": str(_("Department response submitted successfully.")),
|
|
"redirect_url": redirect_url,
|
|
})
|
|
|
|
messages.success(request, _("Department response submitted successfully."))
|
|
if user.department:
|
|
return redirect("organizations:department_detail", pk=user.department.pk)
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def involved_department_review_response(request, pk):
|
|
"""
|
|
Review (accept/reject) a department's response to a complaint.
|
|
On rejection, notifies the champion and resets for resubmission.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment
|
|
|
|
involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
|
|
complaint = involved_dept.complaint
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin() or user.is_hospital_admin()
|
|
or user.is_px_management() or user.is_px_employee()
|
|
):
|
|
messages.error(request, _("You don't have permission to review department responses."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
if not involved_dept.response_submitted:
|
|
messages.error(request, _("No department response to review."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
if involved_dept.manager_review_status != "approved":
|
|
messages.error(request, _("This response has not been approved by the department manager yet."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
acceptance = request.POST.get("acceptance_status")
|
|
if acceptance not in ("acceptable", "not_acceptable"):
|
|
messages.error(request, _("Invalid acceptance status."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
notes = request.POST.get("acceptance_notes", "").strip()
|
|
|
|
involved_dept.acceptance_status = acceptance
|
|
involved_dept.accepted_by = user
|
|
involved_dept.accepted_at = timezone.now()
|
|
involved_dept.acceptance_notes = notes
|
|
|
|
if acceptance == "not_acceptable":
|
|
involved_dept.response_submitted = False
|
|
involved_dept.response_submitted_at = None
|
|
involved_dept.response_notes = ""
|
|
involved_dept.response_notes_en = ""
|
|
involved_dept.response_notes_ar = ""
|
|
involved_dept.acceptance_status = "not_acceptable"
|
|
|
|
involved_dept.save()
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Department response from {involved_dept.department.name} marked as {acceptance} by {user.get_full_name()}. {notes}",
|
|
created_by=user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="complaint_dept_response_review",
|
|
description=f"Department response for complaint {complaint.reference_number} marked as {acceptance}",
|
|
user=user,
|
|
content_object=complaint,
|
|
)
|
|
|
|
if acceptance == "not_acceptable":
|
|
dept = involved_dept.department
|
|
recipients = set()
|
|
if dept.champion and dept.champion.user and dept.champion.user.email:
|
|
recipients.add(dept.champion.user.email)
|
|
if dept.manager and dept.manager.email:
|
|
recipients.add(dept.manager.email)
|
|
for email in recipients:
|
|
try:
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
NotificationService.send_email(
|
|
email,
|
|
subject=f"Response Rejected - {complaint.reference_number}",
|
|
message=(
|
|
f"Your department's response for complaint {complaint.reference_number} was not accepted.\n\n"
|
|
f"Reason: {notes or 'No reason provided.'}\n\n"
|
|
f"Please submit a new response."
|
|
),
|
|
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;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Response Not Accepted</h2>
|
|
<p>Your department's response for complaint <strong>{complaint.reference_number}</strong> was not accepted.</p>
|
|
<p><strong>Reason:</strong> {notes or 'No reason provided.'}</p>
|
|
<p style="margin-top: 16px;">Please review and submit a new response.</p>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=complaint,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send rejection notification: {e}")
|
|
|
|
messages.success(request, _(f"Department response marked as {acceptance}."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def involved_department_reject_routing(request, pk):
|
|
"""
|
|
Champion / department manager rejects the routing of a complaint to their
|
|
department as 'wrong department'. Only the involved department's routing is
|
|
affected; if it was the primary department, complaint.department is cleared.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment
|
|
from apps.complaints.services.complaint_service import (
|
|
reject_department_routing,
|
|
RoutingRejectionError,
|
|
)
|
|
from apps.organizations.models import Department
|
|
|
|
involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
|
|
complaint = involved_dept.complaint
|
|
department = involved_dept.department
|
|
user = request.user
|
|
|
|
# Permission: champion or manager of this department, the assigned handler,
|
|
# or anyone with complaint management rights.
|
|
is_dept_champion = user.is_champion() and user.department == department
|
|
is_dept_manager = user.is_department_manager() and user.department == department
|
|
can_act = (
|
|
is_dept_champion
|
|
or is_dept_manager
|
|
or involved_dept.assigned_to == user
|
|
or can_manage_complaint(user, complaint)
|
|
)
|
|
if not can_act:
|
|
messages.error(request, _("You don't have permission to reject this routing."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
reason = request.POST.get("rejection_reason", "").strip()
|
|
suggested_id = request.POST.get("suggested_department_id", "").strip()
|
|
suggested = None
|
|
if suggested_id:
|
|
suggested = Department.objects.filter(id=suggested_id, hospital=complaint.hospital).first()
|
|
|
|
if not reason:
|
|
messages.error(request, _("Please provide a reason for rejecting this routing."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
staff = getattr(user, "staff_profile", None)
|
|
try:
|
|
reject_department_routing(
|
|
involved_dept,
|
|
staff=staff,
|
|
user=user,
|
|
reason=reason,
|
|
suggested_department=suggested,
|
|
)
|
|
except RoutingRejectionError as exc:
|
|
messages.error(
|
|
request,
|
|
str(exc) if str(exc) else _("This routing can no longer be rejected."),
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
messages.success(
|
|
request,
|
|
_(f"Routing to {department.get_localized_name()} rejected. The PX team has been notified."),
|
|
)
|
|
return redirect("organizations:department_detail", pk=department.pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def involved_staff_add(request, complaint_pk):
|
|
"""
|
|
Add an involved staff member to a complaint.
|
|
|
|
Allows assigning multiple staff members with different roles:
|
|
- Accused/Involved: Staff member involved in the incident
|
|
- Witness: Staff member who witnessed the incident
|
|
- Responsible: Staff responsible for resolution
|
|
- Investigator: Staff investigating the complaint
|
|
- Support: Support staff
|
|
- PX Employee: Coordination role
|
|
"""
|
|
complaint = get_object_or_404(Complaint, pk=complaint_pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintInvolvedStaffForm(request.POST, complaint=complaint, user=user)
|
|
|
|
if form.is_valid():
|
|
involved_staff = form.save(commit=False)
|
|
involved_staff.complaint = complaint
|
|
involved_staff.added_by = user
|
|
involved_staff.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Staff added: {involved_staff.staff} ({involved_staff.get_role_display()})",
|
|
created_by=user,
|
|
)
|
|
|
|
# Audit log
|
|
AuditService.log_event(
|
|
event_type="complaint_staff_added",
|
|
description=f"Staff {involved_staff.staff} added to complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=complaint,
|
|
metadata={
|
|
"staff_id": str(involved_staff.staff.pk),
|
|
"staff_name": str(involved_staff.staff),
|
|
"role": involved_staff.role,
|
|
},
|
|
)
|
|
|
|
success_msg = _("Staff member '%(staff)s' added successfully as %(role)s.") % {
|
|
"staff": involved_staff.staff,
|
|
"role": involved_staff.get_role_display(),
|
|
}
|
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
return JsonResponse({"success": True, "message": str(success_msg)})
|
|
messages.success(request, success_msg)
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
else:
|
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
return JsonResponse(
|
|
{"success": False, "errors": form.errors.get_json_data()},
|
|
status=400,
|
|
)
|
|
messages.error(request, _("Please correct the errors below."))
|
|
else:
|
|
form = ComplaintInvolvedStaffForm(complaint=complaint, user=user)
|
|
|
|
context = {
|
|
"form": form,
|
|
"complaint": complaint,
|
|
"title": _("Add Involved Staff"),
|
|
}
|
|
|
|
return render(request, "complaints/involved_staff_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def involved_staff_edit(request, pk):
|
|
"""
|
|
Edit an involved staff member's details.
|
|
"""
|
|
from .models import ComplaintInvolvedStaff
|
|
|
|
involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk)
|
|
complaint = involved_staff.complaint
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintInvolvedStaffForm(request.POST, instance=involved_staff, complaint=complaint, user=user)
|
|
|
|
if form.is_valid():
|
|
form.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Staff updated: {involved_staff.staff} ({involved_staff.get_role_display()})",
|
|
created_by=user,
|
|
)
|
|
|
|
messages.success(request, _("Staff involvement updated successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
else:
|
|
messages.error(request, _("Please correct the errors below."))
|
|
else:
|
|
form = ComplaintInvolvedStaffForm(instance=involved_staff, complaint=complaint, user=user)
|
|
|
|
context = {
|
|
"form": form,
|
|
"complaint": complaint,
|
|
"involved_staff": involved_staff,
|
|
"title": _("Edit Involved Staff"),
|
|
}
|
|
|
|
return render(request, "complaints/involved_staff_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def involved_staff_remove(request, pk):
|
|
"""
|
|
Remove an involved staff member from a complaint.
|
|
"""
|
|
from .models import ComplaintInvolvedStaff
|
|
|
|
involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk)
|
|
complaint = involved_staff.complaint
|
|
staff_name = str(involved_staff.staff)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
# If the user is removing the complaint's primary staff (accused) involvement,
|
|
# record it so ensure_involved_records() does not auto-recreate it on the
|
|
# next detail-view render. Stored as the staff id; if complaint.staff later
|
|
# changes to a different staff, auto-recreation resumes automatically.
|
|
if involved_staff.staff_id == complaint.staff_id and complaint.staff_id is not None:
|
|
complaint.primary_staff_involved_removed_id = complaint.staff_id
|
|
complaint.save(update_fields=["primary_staff_involved_removed"])
|
|
|
|
involved_staff.delete()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Staff removed: {staff_name}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Audit log
|
|
AuditService.log_event(
|
|
event_type="complaint_staff_removed",
|
|
description=f"Staff {staff_name} removed from complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=complaint,
|
|
)
|
|
|
|
messages.success(request, _("Staff member removed successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def involved_staff_explanation(request, pk):
|
|
"""
|
|
Submit an explanation as an involved staff member.
|
|
"""
|
|
from .models import ComplaintInvolvedStaff
|
|
|
|
involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk)
|
|
complaint = involved_staff.complaint
|
|
user = request.user
|
|
|
|
# Check permission - must be the staff member themselves or have management rights
|
|
can_submit = (
|
|
involved_staff.staff.user == user if hasattr(involved_staff.staff, "user") else False
|
|
) or can_manage_complaint(user, complaint)
|
|
|
|
if not can_submit:
|
|
messages.error(request, _("You don't have permission to submit an explanation for this staff member."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
form = StaffExplanationForm(request.POST, instance=involved_staff)
|
|
|
|
if form.is_valid():
|
|
involved_staff = form.save(commit=False)
|
|
involved_staff.explanation_received = True
|
|
involved_staff.explanation_received_at = timezone.now()
|
|
involved_staff.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Explanation submitted by {involved_staff.staff}",
|
|
created_by=user,
|
|
)
|
|
|
|
messages.success(request, _("Explanation submitted successfully."))
|
|
else:
|
|
messages.error(request, _("Please provide a valid explanation."))
|
|
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
# =============================================================================
|
|
# Complaint Adverse Action Views
|
|
# =============================================================================
|
|
|
|
|
|
@login_required
|
|
def adverse_action_list(request):
|
|
"""
|
|
List all adverse actions related to complaints.
|
|
|
|
Features:
|
|
- Filter by status, severity, action type
|
|
- Search by complaint reference or description
|
|
- Paginated results
|
|
"""
|
|
# Check permission
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
messages.error(request, _("You don't have permission to view adverse actions."))
|
|
return redirect("complaints:complaint_list")
|
|
|
|
# Base queryset
|
|
queryset = ComplaintAdverseAction.objects.select_related(
|
|
"complaint", "complaint__hospital", "reported_by"
|
|
).prefetch_related("involved_staff")
|
|
|
|
# Apply filters
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
severity_filter = request.GET.get("severity")
|
|
if severity_filter:
|
|
queryset = queryset.filter(severity=severity_filter)
|
|
|
|
action_type_filter = request.GET.get("action_type")
|
|
if action_type_filter:
|
|
queryset = queryset.filter(action_type=action_type_filter)
|
|
|
|
# Filter by hospital for hospital admins
|
|
if user.is_hospital_admin() and user.hospital:
|
|
queryset = queryset.filter(complaint__hospital=user.hospital)
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(complaint__reference_number__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(patient_impact__icontains=search_query)
|
|
)
|
|
|
|
# Pagination
|
|
paginator = Paginator(queryset.order_by("-incident_date"), 25)
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"status_choices": ComplaintAdverseAction.VerificationStatus.choices,
|
|
"severity_choices": ComplaintAdverseAction.SeverityLevel.choices,
|
|
"action_type_choices": ComplaintAdverseAction.ActionType.choices,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "complaints/adverse_action_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def adverse_action_add(request, complaint_pk):
|
|
"""
|
|
Add a new adverse action to a complaint.
|
|
"""
|
|
complaint = get_object_or_404(Complaint, pk=complaint_pk)
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to add adverse actions to this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint_pk)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
# Parse form data
|
|
action_type = request.POST.get("action_type")
|
|
severity = request.POST.get("severity")
|
|
description = request.POST.get("description", "").strip()
|
|
patient_impact = request.POST.get("patient_impact", "").strip()
|
|
incident_date = request.POST.get("incident_date")
|
|
location = request.POST.get("location", "").strip()
|
|
involved_staff_ids = request.POST.getlist("involved_staff")
|
|
|
|
if not description:
|
|
messages.error(request, _("Description is required."))
|
|
return render(
|
|
request,
|
|
"complaints/adverse_action_form.html",
|
|
{
|
|
"complaint": complaint,
|
|
"action_type_choices": ComplaintAdverseAction.ActionType.choices,
|
|
"severity_choices": ComplaintAdverseAction.SeverityLevel.choices,
|
|
"staff_list": Staff.objects.filter(hospital=complaint.hospital, status="active"),
|
|
},
|
|
)
|
|
|
|
# Create adverse action
|
|
adverse_action = ComplaintAdverseAction.objects.create(
|
|
complaint=complaint,
|
|
action_type=action_type or ComplaintAdverseAction.ActionType.OTHER,
|
|
severity=severity or ComplaintAdverseAction.SeverityLevel.MEDIUM,
|
|
description=description,
|
|
patient_impact=patient_impact,
|
|
incident_date=incident_date or timezone.now(),
|
|
location=location,
|
|
reported_by=user,
|
|
status=ComplaintAdverseAction.VerificationStatus.REPORTED,
|
|
)
|
|
|
|
# Add involved staff
|
|
if involved_staff_ids:
|
|
adverse_action.involved_staff.set(involved_staff_ids)
|
|
|
|
# Create timeline entry
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Adverse action reported: {adverse_action.get_action_type_display()}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="adverse_action_created",
|
|
description=f"Adverse action added to complaint {complaint.reference_number}: {adverse_action.get_action_type_display()}",
|
|
user=user,
|
|
content_object=adverse_action,
|
|
metadata={
|
|
"complaint_id": str(complaint.id),
|
|
"action_type": action_type,
|
|
"severity": severity,
|
|
},
|
|
)
|
|
|
|
messages.success(request, _("Adverse action reported successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint_pk)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating adverse action: {str(e)}", exc_info=True)
|
|
messages.error(request, f"Error creating adverse action: {str(e)}")
|
|
|
|
context = {
|
|
"complaint": complaint,
|
|
"action_type_choices": ComplaintAdverseAction.ActionType.choices,
|
|
"severity_choices": ComplaintAdverseAction.SeverityLevel.choices,
|
|
"staff_list": Staff.objects.filter(hospital=complaint.hospital, status="active")
|
|
if complaint.hospital
|
|
else Staff.objects.filter(status="active"),
|
|
}
|
|
return render(request, "complaints/adverse_action_form.html", context)
|
|
|
|
|
|
@login_required
|
|
def adverse_action_edit(request, pk):
|
|
"""
|
|
Edit an existing adverse action.
|
|
"""
|
|
adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk)
|
|
complaint = adverse_action.complaint
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to edit this adverse action."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
# Update fields
|
|
adverse_action.action_type = request.POST.get("action_type", adverse_action.action_type)
|
|
adverse_action.severity = request.POST.get("severity", adverse_action.severity)
|
|
adverse_action.description = request.POST.get("description", adverse_action.description).strip()
|
|
adverse_action.patient_impact = request.POST.get("patient_impact", adverse_action.patient_impact).strip()
|
|
adverse_action.location = request.POST.get("location", adverse_action.location).strip()
|
|
|
|
incident_date = request.POST.get("incident_date")
|
|
if incident_date:
|
|
adverse_action.incident_date = incident_date
|
|
|
|
# Update involved staff
|
|
involved_staff_ids = request.POST.getlist("involved_staff")
|
|
if involved_staff_ids:
|
|
adverse_action.involved_staff.set(involved_staff_ids)
|
|
|
|
adverse_action.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="adverse_action_updated",
|
|
description=f"Adverse action updated for complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=adverse_action,
|
|
)
|
|
|
|
messages.success(request, _("Adverse action updated successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating adverse action: {str(e)}", exc_info=True)
|
|
messages.error(request, f"Error updating adverse action: {str(e)}")
|
|
|
|
context = {
|
|
"adverse_action": adverse_action,
|
|
"complaint": complaint,
|
|
"action_type_choices": ComplaintAdverseAction.ActionType.choices,
|
|
"severity_choices": ComplaintAdverseAction.SeverityLevel.choices,
|
|
"staff_list": Staff.objects.filter(hospital=complaint.hospital, status="active")
|
|
if complaint.hospital
|
|
else Staff.objects.filter(status="active"),
|
|
"selected_staff": list(adverse_action.involved_staff.values_list("id", flat=True)),
|
|
}
|
|
|
|
return render(request, "complaints/adverse_action_form.html", context)
|
|
|
|
|
|
@login_required
|
|
def adverse_action_update_status(request, pk):
|
|
"""
|
|
Update the status of an adverse action (investigation, resolution).
|
|
"""
|
|
adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk)
|
|
complaint = adverse_action.complaint
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to update this adverse action."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
new_status = request.POST.get("status")
|
|
investigation_notes = request.POST.get("investigation_notes", "").strip()
|
|
resolution = request.POST.get("resolution", "").strip()
|
|
|
|
old_status = adverse_action.status
|
|
adverse_action.status = new_status
|
|
|
|
if investigation_notes:
|
|
adverse_action.investigation_notes = investigation_notes
|
|
|
|
if resolution:
|
|
adverse_action.resolution = resolution
|
|
|
|
# Handle status-specific updates
|
|
if new_status == ComplaintAdverseAction.VerificationStatus.UNDER_INVESTIGATION:
|
|
adverse_action.investigated_by = user
|
|
adverse_action.investigated_at = timezone.now()
|
|
|
|
elif new_status == ComplaintAdverseAction.VerificationStatus.RESOLVED:
|
|
adverse_action.resolved_by = user
|
|
adverse_action.resolved_at = timezone.now()
|
|
|
|
elif new_status == ComplaintAdverseAction.VerificationStatus.VERIFIED:
|
|
adverse_action.investigated_by = user
|
|
adverse_action.investigated_at = timezone.now()
|
|
|
|
adverse_action.save()
|
|
|
|
# Create timeline entry
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Adverse action status changed from {adverse_action.get_status_display()} to {dict(ComplaintAdverseAction.VerificationStatus.choices)[new_status]}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="adverse_action_status_changed",
|
|
description=f"Adverse action status changed from {old_status} to {new_status} for complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=adverse_action,
|
|
metadata={
|
|
"old_status": old_status,
|
|
"new_status": new_status,
|
|
},
|
|
)
|
|
|
|
messages.success(request, _("Adverse action status updated successfully."))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating adverse action status: {str(e)}", exc_info=True)
|
|
messages.error(request, f"Error updating status: {str(e)}")
|
|
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
|
|
@login_required
|
|
def adverse_action_escalate(request, pk):
|
|
"""
|
|
Escalate an adverse action to management.
|
|
"""
|
|
adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk)
|
|
complaint = adverse_action.complaint
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to escalate this adverse action."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
adverse_action.is_escalated = True
|
|
adverse_action.escalated_at = timezone.now()
|
|
adverse_action.save()
|
|
|
|
# Create timeline entry
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="escalation",
|
|
message=f"Adverse action escalated: {adverse_action.get_action_type_display()}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="adverse_action_escalated",
|
|
description=f"Adverse action escalated for complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=adverse_action,
|
|
)
|
|
|
|
messages.success(request, _("Adverse action escalated successfully."))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error escalating adverse action: {str(e)}", exc_info=True)
|
|
messages.error(request, f"Error escalating: {str(e)}")
|
|
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
|
|
@login_required
|
|
def adverse_action_delete(request, pk):
|
|
"""
|
|
Delete an adverse action.
|
|
"""
|
|
adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk)
|
|
complaint = adverse_action.complaint
|
|
user = request.user
|
|
|
|
# Check permission - only PX Admin or Hospital Admin can delete
|
|
if not (user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital)):
|
|
messages.error(request, _("You don't have permission to delete adverse actions."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
complaint_pk = complaint.id
|
|
|
|
# Log before deletion
|
|
AuditService.log_event(
|
|
event_type="adverse_action_deleted",
|
|
description=f"Adverse action deleted from complaint {complaint.reference_number}: {adverse_action.get_action_type_display()}",
|
|
user=user,
|
|
metadata={
|
|
"complaint_id": str(complaint.id),
|
|
"action_type": adverse_action.action_type,
|
|
"description": adverse_action.description[:100],
|
|
},
|
|
)
|
|
|
|
adverse_action.delete()
|
|
messages.success(request, _("Adverse action deleted successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint_pk)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error deleting adverse action: {str(e)}", exc_info=True)
|
|
messages.error(request, f"Error deleting: {str(e)}")
|
|
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
|
|
def patient_complaint_portal(request, token):
|
|
"""Hospital selection page - first step of patient complaint flow."""
|
|
from apps.complaints.models import PatientComplaintSession
|
|
from apps.integrations.models import HISPatientVisit
|
|
from django.http import Http404
|
|
from django.db.models import Count
|
|
|
|
try:
|
|
session = PatientComplaintSession.objects.get(token=token, is_active=True)
|
|
except PatientComplaintSession.DoesNotExist:
|
|
raise Http404("Invalid or expired link")
|
|
|
|
if session.is_expired():
|
|
return render(request, "complaints/patient_complaint_expired.html")
|
|
|
|
patient = session.patient
|
|
|
|
hospitals = (
|
|
HISPatientVisit.objects.filter(patient=patient)
|
|
.values("hospital__id", "hospital__name", "hospital__name_ar")
|
|
.annotate(visit_count=Count("id"))
|
|
.order_by("-visit_count")
|
|
)
|
|
|
|
context = {
|
|
"patient": patient,
|
|
"session": session,
|
|
"hospitals": hospitals,
|
|
}
|
|
return render(request, "complaints/patient_complaint_portal.html", context)
|
|
|
|
|
|
def patient_complaint_hospital_visits(request, token, hospital_id):
|
|
"""Visit list for a selected hospital - second step."""
|
|
from apps.complaints.models import PatientComplaintSession
|
|
from apps.integrations.models import HISPatientVisit
|
|
from django.http import Http404
|
|
|
|
try:
|
|
session = PatientComplaintSession.objects.get(token=token, is_active=True)
|
|
except PatientComplaintSession.DoesNotExist:
|
|
raise Http404("Invalid or expired link")
|
|
|
|
if session.is_expired():
|
|
return render(request, "complaints/patient_complaint_expired.html")
|
|
|
|
patient = session.patient
|
|
|
|
visits = HISPatientVisit.objects.filter(patient=patient, hospital_id=hospital_id).order_by("-admit_date")[:50]
|
|
|
|
hospital = None
|
|
if visits:
|
|
hospital = visits[0].hospital
|
|
|
|
context = {
|
|
"patient": patient,
|
|
"session": session,
|
|
"hospital": hospital,
|
|
"visits": visits,
|
|
}
|
|
return render(request, "complaints/patient_complaint_visits.html", context)
|
|
|
|
|
|
def patient_complaint_visit_form(request, token, visit_id):
|
|
"""Complaint form for a selected visit - third step."""
|
|
from apps.complaints.models import PatientComplaintSession, Complaint
|
|
from apps.integrations.models import HISPatientVisit
|
|
from django.http import Http404
|
|
|
|
try:
|
|
session = PatientComplaintSession.objects.get(token=token, is_active=True)
|
|
except PatientComplaintSession.DoesNotExist:
|
|
raise Http404("Invalid or expired link")
|
|
|
|
if session.is_expired():
|
|
return render(request, "complaints/patient_complaint_expired.html")
|
|
|
|
patient = session.patient
|
|
visit = get_object_or_404(HISPatientVisit, id=visit_id, patient=patient)
|
|
|
|
if request.method == "POST":
|
|
description = request.POST.get("description", "").strip()
|
|
if not description:
|
|
return render(
|
|
request,
|
|
"complaints/patient_complaint_visit_form.html",
|
|
{
|
|
"patient": patient,
|
|
"session": session,
|
|
"visit": visit,
|
|
"error": "Please enter a description of your complaint.",
|
|
},
|
|
)
|
|
|
|
title = f"Complaint - {visit.patient_type} Visit ({visit.admission_id})"
|
|
complaint = Complaint.objects.create(
|
|
patient=patient,
|
|
hospital=visit.hospital,
|
|
title=title,
|
|
description=description,
|
|
encounter_id=visit.admission_id,
|
|
complaint_source_type="internal",
|
|
priority="medium",
|
|
severity="medium",
|
|
status="open",
|
|
metadata={
|
|
"session_id": str(session.id),
|
|
"visit_id": str(visit.id),
|
|
"visit_type": visit.patient_type,
|
|
"submitted_via": "patient_link",
|
|
},
|
|
)
|
|
|
|
session.is_active = False
|
|
session.save()
|
|
|
|
return render(
|
|
request,
|
|
"complaints/patient_complaint_success.html",
|
|
{
|
|
"complaint": complaint,
|
|
"patient": patient,
|
|
},
|
|
)
|
|
|
|
context = {
|
|
"patient": patient,
|
|
"session": session,
|
|
"visit": visit,
|
|
}
|
|
return render(request, "complaints/patient_complaint_visit_form.html", context)
|
|
|
|
|
|
# ==================== Government Ticket Views ====================
|
|
|
|
|
|
@login_required
|
|
def government_ticket_list(request):
|
|
"""List government tickets with filters"""
|
|
# Permission check: PX Admin or PX Employee only
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to view government tickets."))
|
|
return redirect("dashboard:command-center")
|
|
|
|
# Base queryset
|
|
queryset = GovernmentTicket.objects.select_related("source", "department", "assigned_to").all()
|
|
|
|
# Filters
|
|
source_filter = request.GET.get("source")
|
|
status_filter = request.GET.get("status")
|
|
department_filter = request.GET.get("department")
|
|
search_query = request.GET.get("q", "").strip()
|
|
converted_filter = request.GET.get("converted")
|
|
|
|
if source_filter:
|
|
queryset = queryset.filter(source_id=source_filter)
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
if department_filter:
|
|
queryset = queryset.filter(department_id=department_filter)
|
|
if converted_filter:
|
|
is_converted = converted_filter == "yes"
|
|
queryset = queryset.filter(converted_to_complaint=is_converted)
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(ticket_number__icontains=search_query)
|
|
| Q(complainant_name__icontains=search_query)
|
|
| Q(national_id__icontains=search_query)
|
|
| Q(content__icontains=search_query)
|
|
)
|
|
|
|
# Pagination
|
|
paginator = Paginator(queryset.order_by("-received_date"), 25)
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
# Context
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"sources": PXSource.objects.filter(source_type="government", is_active=True),
|
|
"departments": Department.objects.filter(status="active"),
|
|
"status_choices": GovernmentTicket.TicketStatus.choices,
|
|
"source_filter": source_filter,
|
|
"status_filter": status_filter,
|
|
"department_filter": department_filter,
|
|
"converted_filter": converted_filter,
|
|
"search_query": search_query,
|
|
"total_count": queryset.count(),
|
|
}
|
|
|
|
return render(request, "complaints/government_ticket_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def government_ticket_detail(request, pk):
|
|
"""Detail view for a government ticket"""
|
|
ticket = get_object_or_404(
|
|
GovernmentTicket.objects.select_related("source", "department", "assigned_to", "complaint"),
|
|
pk=pk,
|
|
)
|
|
|
|
# Permission check
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to view this ticket."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
context = {
|
|
"ticket": ticket,
|
|
}
|
|
return render(request, "complaints/government_ticket_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
def government_ticket_create(request):
|
|
"""Create a new government ticket"""
|
|
# Permission check
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to create government tickets."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
if request.method == "POST":
|
|
form = GovernmentTicketForm(request.POST)
|
|
if form.is_valid():
|
|
ticket = form.save()
|
|
messages.success(request, _("Government ticket created successfully."))
|
|
return redirect("complaints:government_ticket_detail", pk=ticket.pk)
|
|
else:
|
|
form = GovernmentTicketForm()
|
|
|
|
context = {
|
|
"form": form,
|
|
"is_create": True,
|
|
}
|
|
return render(request, "complaints/government_ticket_form.html", context)
|
|
|
|
|
|
@login_required
|
|
def government_ticket_update(request, pk):
|
|
"""Update an existing government ticket"""
|
|
ticket = get_object_or_404(GovernmentTicket, pk=pk)
|
|
|
|
# Permission check
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to update this ticket."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
if request.method == "POST":
|
|
form = GovernmentTicketForm(request.POST, instance=ticket)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, _("Government ticket updated successfully."))
|
|
return redirect("complaints:government_ticket_detail", pk=ticket.pk)
|
|
else:
|
|
form = GovernmentTicketForm(instance=ticket)
|
|
|
|
context = {
|
|
"form": form,
|
|
"ticket": ticket,
|
|
"is_create": False,
|
|
}
|
|
return render(request, "complaints/government_ticket_form.html", context)
|
|
|
|
|
|
@login_required
|
|
def convert_to_complaint(request, pk):
|
|
"""Convert a government ticket to a complaint"""
|
|
ticket = get_object_or_404(GovernmentTicket, pk=pk)
|
|
|
|
# Permission check
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to convert this ticket."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
if ticket.converted_to_complaint:
|
|
messages.warning(request, _("This ticket has already been converted to a complaint."))
|
|
return redirect("complaints:government_ticket_detail", pk=ticket.pk)
|
|
|
|
# Build redirect URL to complaint create form with pre-filled data
|
|
import urllib.parse
|
|
|
|
params = {
|
|
"source": ticket.source_id,
|
|
"complaint_source_type": "external" if ticket.source.code.upper() in ("MOH", "CCHI") else "internal",
|
|
"title": f"[{ticket.ticket_number}] {ticket.classification or 'Government Ticket'}",
|
|
"description": ticket.content,
|
|
"main_section": ticket.main_section_id or "",
|
|
"subsection": ticket.subsection_id or "",
|
|
"patient_name": ticket.complainant_name,
|
|
"contact_phone": ticket.contact_number or "",
|
|
"moh_reference": ticket.ticket_number if "moh" in ticket.source.code.lower() else "",
|
|
"chi_reference": ticket.ticket_number if "chi" in ticket.source.code.lower() else "",
|
|
}
|
|
|
|
query_string = urllib.parse.urlencode({k: v for k, v in params.items() if v})
|
|
url = f"{reverse('complaints:complaint_create')}?{query_string}"
|
|
|
|
return redirect(url)
|
|
|
|
|
|
@login_required
|
|
def government_ticket_import(request):
|
|
"""Import government tickets from Excel file"""
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to import government tickets."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
preview_data = None
|
|
import_count = 0
|
|
error_count = 0
|
|
errors = []
|
|
|
|
if request.method == "POST":
|
|
action = request.POST.get("action")
|
|
|
|
if action == "preview":
|
|
# Handle file upload and show preview
|
|
file = request.FILES.get("file")
|
|
if not file:
|
|
messages.error(request, _("Please select a file to import."))
|
|
return redirect("complaints:government_ticket_import")
|
|
|
|
try:
|
|
import pandas as pd
|
|
|
|
# Read Excel file
|
|
df = pd.read_excel(file, header=1) # Header is on row 2 (index 1)
|
|
|
|
# Column mapping (Arabic to English)
|
|
column_map = {
|
|
"رقم التذكرة": "ticket_number",
|
|
"اسم المشتكي": "complainant_name",
|
|
"رقم الهوية": "national_id",
|
|
"رقم التواصل": "contact_number",
|
|
"الموقع": "location",
|
|
"القسم الرئيسي": "main_section",
|
|
"القسم الفرعي": "subsection",
|
|
"تاريخ إنشاء التذكرة": "received_date",
|
|
"وقت إنشاء التذكرة": "received_time",
|
|
"تصنيف الشكوى": "classification",
|
|
"محتوى الشكوى": "content",
|
|
"حالة الشكوى": "status",
|
|
"اسم الموظف": "assigned_to",
|
|
}
|
|
|
|
# Rename columns
|
|
df.rename(columns=column_map, inplace=True)
|
|
|
|
# Take first 5 rows for preview
|
|
preview_df = df.head(5)
|
|
preview_data = preview_df.fillna("").to_dict("records")
|
|
|
|
# Store full dataframe in session for actual import
|
|
request.session["import_df"] = df.to_json()
|
|
request.session["import_filename"] = file.name
|
|
|
|
messages.info(request, _("Preview ready. Review the data below and confirm to import."))
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Error reading file: {str(e)}")
|
|
return redirect("complaints:government_ticket_import")
|
|
|
|
elif action == "import":
|
|
# Handle actual import
|
|
json_data = request.session.get("import_df")
|
|
if not json_data:
|
|
messages.error(request, _("No data to import. Please upload a file first."))
|
|
return redirect("complaints:government_ticket_import")
|
|
|
|
try:
|
|
import pandas as pd
|
|
from apps.px_sources.models import PXSource
|
|
from apps.organizations.models import LegacyLocation, LegacyMainSection, LegacySubSection
|
|
from apps.accounts.models import User
|
|
|
|
df = pd.read_json(json_data)
|
|
|
|
# Get MOH source (default for import)
|
|
moh_source = PXSource.objects.filter(code__icontains="MOH", source_type="government").first()
|
|
if not moh_source:
|
|
messages.error(request, _("MOH source not found in system. Please configure it first."))
|
|
return redirect("complaints:government_ticket_import")
|
|
|
|
# Status mapping
|
|
status_map = {
|
|
"مفتوحة": "pending",
|
|
"قيد المعالجة": "in_progress",
|
|
"تم الحل": "resolved",
|
|
"مغلقة": "closed",
|
|
"pending": "pending",
|
|
"in_progress": "in_progress",
|
|
"resolved": "resolved",
|
|
"closed": "closed",
|
|
}
|
|
|
|
for idx, row in df.iterrows():
|
|
try:
|
|
# Skip if ticket number is missing
|
|
if pd.isna(row.get("ticket_number")):
|
|
errors.append(f"Row {idx + 1}: Missing ticket number")
|
|
error_count += 1
|
|
continue
|
|
|
|
# Check for duplicate
|
|
if GovernmentTicket.objects.filter(ticket_number=str(row["ticket_number"])).exists():
|
|
errors.append(f"Row {idx + 1}: Ticket {row['ticket_number']} already exists")
|
|
error_count += 1
|
|
continue
|
|
|
|
# Parse received date
|
|
received_date = None
|
|
if pd.notna(row.get("received_date")):
|
|
received_date = pd.to_datetime(row["received_date"])
|
|
if pd.notna(row.get("received_time")):
|
|
time_part = pd.to_datetime(row["received_time"])
|
|
received_date = received_date.replace(
|
|
hour=time_part.hour,
|
|
minute=time_part.minute,
|
|
second=time_part.second,
|
|
)
|
|
else:
|
|
received_date = timezone.now()
|
|
|
|
# Lookup location
|
|
ticket_location = None
|
|
if pd.notna(row.get("location")):
|
|
loc_name = str(row["location"]).strip()
|
|
ticket_location = LegacyLocation.objects.filter(
|
|
models.Q(name_en__icontains=loc_name) | models.Q(name_ar__icontains=loc_name)
|
|
).first()
|
|
|
|
# Lookup main section
|
|
ticket_main_section = None
|
|
if pd.notna(row.get("main_section")):
|
|
sec_name = str(row["main_section"]).strip()
|
|
ticket_main_section = LegacyMainSection.objects.filter(
|
|
models.Q(name_en__icontains=sec_name) | models.Q(name_ar__icontains=sec_name)
|
|
).first()
|
|
|
|
# Lookup subsection
|
|
ticket_subsection = None
|
|
if pd.notna(row.get("subsection")):
|
|
sub_name = str(row["subsection"]).strip()
|
|
qs = LegacySubSection.objects.filter(
|
|
models.Q(name_en__icontains=sub_name) | models.Q(name_ar__icontains=sub_name)
|
|
)
|
|
if ticket_location:
|
|
qs = qs.filter(location=ticket_location)
|
|
if ticket_main_section:
|
|
qs = qs.filter(main_section=ticket_main_section)
|
|
ticket_subsection = qs.first()
|
|
|
|
# Lookup assigned user
|
|
assigned_user = None
|
|
if pd.notna(row.get("assigned_to")):
|
|
assigned_user = User.objects.filter(
|
|
models.Q(first_name__icontains=str(row["assigned_to"]).strip()) |
|
|
models.Q(last_name__icontains=str(row["assigned_to"]).strip()) |
|
|
models.Q(get_full_name__icontains=str(row["assigned_to"]).strip())
|
|
).first()
|
|
|
|
# Map status
|
|
status = "pending"
|
|
if pd.notna(row.get("status")):
|
|
status = status_map.get(str(row["status"]).strip().lower(), "pending")
|
|
|
|
# Create ticket
|
|
GovernmentTicket.objects.create(
|
|
source=moh_source,
|
|
ticket_number=str(row["ticket_number"]).strip(),
|
|
complainant_name=str(row.get("complainant_name", "")).strip() or "Unknown",
|
|
national_id=str(row.get("national_id", "")).strip() if pd.notna(row.get("national_id")) else "",
|
|
contact_number=str(row.get("contact_number", "")).strip() if pd.notna(row.get("contact_number")) else "",
|
|
legacy_location=ticket_location,
|
|
legacy_main_section=ticket_main_section,
|
|
legacy_subsection=ticket_subsection,
|
|
received_date=received_date,
|
|
classification=str(row.get("classification", "")).strip() if pd.notna(row.get("classification")) else "",
|
|
content=str(row.get("content", "")).strip() if pd.notna(row.get("content")) else "",
|
|
status=status,
|
|
assigned_to=assigned_user,
|
|
)
|
|
import_count += 1
|
|
|
|
except Exception as e:
|
|
errors.append(f"Row {idx + 1}: {str(e)}")
|
|
error_count += 1
|
|
|
|
# Clear session data
|
|
del request.session["import_df"]
|
|
del request.session["import_filename"]
|
|
|
|
if import_count > 0:
|
|
messages.success(request, f"Successfully imported {import_count} tickets.")
|
|
if error_count > 0:
|
|
messages.warning(request, f"{error_count} rows had errors.")
|
|
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Import failed: {str(e)}")
|
|
return redirect("complaints:government_ticket_import")
|
|
|
|
context = {
|
|
"preview_data": preview_data,
|
|
"errors": errors,
|
|
"import_count": import_count,
|
|
"error_count": error_count,
|
|
}
|
|
return render(request, "complaints/government_ticket_import.html", context)
|
|
|
|
|
|
@login_required
|
|
def government_ticket_export(request):
|
|
"""Export government tickets to Excel"""
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to export government tickets."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
import pandas as pd
|
|
from django.http import HttpResponse
|
|
|
|
# Get filtered queryset (same filters as list view)
|
|
queryset = GovernmentTicket.objects.select_related("source", "department", "assigned_to").all()
|
|
|
|
source_filter = request.GET.get("source")
|
|
status_filter = request.GET.get("status")
|
|
department_filter = request.GET.get("department")
|
|
search_query = request.GET.get("q", "").strip()
|
|
|
|
if source_filter:
|
|
queryset = queryset.filter(source_id=source_filter)
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
if department_filter:
|
|
queryset = queryset.filter(department_id=department_filter)
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
models.Q(ticket_number__icontains=search_query)
|
|
| models.Q(complainant_name__icontains=search_query)
|
|
| models.Q(national_id__icontains=search_query)
|
|
| models.Q(content__icontains=search_query)
|
|
)
|
|
|
|
# Prepare data
|
|
data = []
|
|
for ticket in queryset.order_by("-received_date"):
|
|
data.append({
|
|
"رقم التذكرة": ticket.ticket_number,
|
|
"اسم المشتكي": ticket.complainant_name,
|
|
"رقم الهوية": ticket.national_id or "",
|
|
"رقم التواصل": ticket.contact_number or "",
|
|
"القسم": ticket.department.name_en if ticket.department else "",
|
|
"نوع الموقع": ticket.department.location_type if ticket.department and ticket.department.location_type else "",
|
|
"تاريخ إنشاء التذكرة": ticket.received_date.strftime("%Y-%m-%d") if ticket.received_date else "",
|
|
"وقت إنشاء التذكرة": ticket.received_date.strftime("%H:%M:%S") if ticket.received_date else "",
|
|
"تصنيف الشكوى": ticket.classification or "",
|
|
"محتوى الشكوى": ticket.content or "",
|
|
"حالة الشكوى": ticket.get_status_display(),
|
|
"اسم الموظف": ticket.assigned_to.get_full_name() if ticket.assigned_to else "",
|
|
"تم التحويل": "نعم" if ticket.converted_to_complaint else "لا",
|
|
})
|
|
|
|
df = pd.DataFrame(data)
|
|
|
|
# Create response
|
|
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
response["Content-Disposition"] = 'attachment; filename="government_tickets.xlsx"'
|
|
|
|
with pd.ExcelWriter(response, engine="openpyxl") as writer:
|
|
df.to_excel(writer, index=False, sheet_name="Government Tickets")
|
|
|
|
return response
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_soft_delete(request, pk):
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
if not (
|
|
request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
or request.user.is_px_management() or request.user.is_px_employee()
|
|
):
|
|
return HttpResponseForbidden(_("You don't have permission to delete complaints."))
|
|
complaint.soft_delete(user=request.user)
|
|
messages.success(request, _("Complaint moved to trash."))
|
|
return redirect("complaints:complaint_list")
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_restore(request, pk):
|
|
complaint = get_object_or_404(Complaint.all_objects, pk=pk, is_deleted=True)
|
|
if not (
|
|
request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
or request.user.is_px_management() or request.user.is_px_employee()
|
|
):
|
|
return HttpResponseForbidden(_("You don't have permission to restore complaints."))
|
|
complaint.restore()
|
|
messages.success(request, _("Complaint restored successfully."))
|
|
return redirect("config:deleted_items")
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_restore(request, pk):
|
|
inquiry = get_object_or_404(Inquiry.all_objects, pk=pk, is_deleted=True)
|
|
if not (
|
|
request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
or request.user.is_px_management() or request.user.is_px_employee()
|
|
):
|
|
return HttpResponseForbidden(_("You don't have permission to restore inquiries."))
|
|
inquiry.restore()
|
|
messages.success(request, _("Inquiry restored successfully."))
|
|
return redirect("config:deleted_items")
|
|
|
|
|
|
@login_required
|
|
def trash_list(request):
|
|
if not (
|
|
request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
or request.user.is_px_management() or request.user.is_px_employee()
|
|
):
|
|
return HttpResponseForbidden(_("You don't have permission to view trash."))
|
|
|
|
deleted_complaints = Complaint.all_objects.filter(is_deleted=True).select_related(
|
|
"hospital", "department", "created_by"
|
|
).order_by("-deleted_at")
|
|
|
|
deleted_observations = Observation.all_objects.filter(is_deleted=True).select_related(
|
|
"hospital", "assigned_department"
|
|
).order_by("-deleted_at")
|
|
|
|
return render(request, "complaints/trash_list.html", {
|
|
"deleted_complaints": deleted_complaints,
|
|
"deleted_observations": deleted_observations,
|
|
})
|