HH/apps/complaints/ui_views.py
2026-07-19 12:27:55 +03:00

7708 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_champion() and (
inquiry.department == user.department
or inquiry.outgoing_department == user.department
))
or user.is_px_management()
or user.is_px_employee()
):
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,
})