HH/apps/complaints/ui_views.py
ismail c5f76b3855
Some checks are pending
Build and Push Docker Image / build (push) Waiting to run
updates
2026-05-11 14:45:30 +03:00

6220 lines
231 KiB
Python

"""
Complaints UI views - Server-rendered templates for complaints console
"""
import logging
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q, Count, Prefetch
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
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
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 (
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."""
stages = []
if complaint.created_at:
stages.append({
"label": _("Created"),
"timestamp": complaint.created_at,
"color": "bg-slate-400",
})
if complaint.activated_at:
stages.append({
"label": _("Activated"),
"timestamp": complaint.activated_at,
"duration_from_prev": _format_duration(complaint.created_at, complaint.activated_at),
"color": "bg-blue-500",
})
if complaint.forwarded_to_dept_at:
stages.append({
"label": _("Forwarded to Department"),
"timestamp": complaint.forwarded_to_dept_at,
"duration_from_prev": _format_duration(complaint.activated_at or complaint.created_at, complaint.forwarded_to_dept_at),
"color": "bg-purple-500",
})
if complaint.response_date:
stages.append({
"label": _("Department Responded"),
"timestamp": complaint.response_date,
"duration_from_prev": _format_duration(complaint.forwarded_to_dept_at or complaint.activated_at, complaint.response_date),
"color": "bg-amber-500",
})
if complaint.resolved_at:
stages.append({
"label": _("Resolved"),
"timestamp": complaint.resolved_at,
"duration_from_prev": _format_duration(complaint.response_date or complaint.forwarded_to_dept_at, complaint.resolved_at),
"color": "bg-green-500",
})
if complaint.closed_at:
stages.append({
"label": _("Closed"),
"timestamp": complaint.closed_at,
"duration_from_prev": _format_duration(complaint.resolved_at or complaint.response_date, complaint.closed_at),
"color": "bg-emerald-600",
})
return stages
def _build_inquiry_stage_timeline(inquiry):
"""Build stage timeline with timestamps and durations for an inquiry."""
stages = []
if inquiry.created_at:
stages.append({
"label": _("Created"),
"timestamp": inquiry.created_at,
"color": "bg-slate-400",
})
if inquiry.activated_at:
stages.append({
"label": _("Activated"),
"timestamp": inquiry.activated_at,
"duration_from_prev": _format_duration(inquiry.created_at, inquiry.activated_at),
"color": "bg-blue-500",
})
if inquiry.transferred_at:
stages.append({
"label": _("Transferred to Department"),
"timestamp": inquiry.transferred_at,
"duration_from_prev": _format_duration(inquiry.activated_at or inquiry.created_at, inquiry.transferred_at),
"color": "bg-purple-500",
})
if inquiry.department_responded_at:
stages.append({
"label": _("Department Responded"),
"timestamp": inquiry.department_responded_at,
"duration_from_prev": _format_duration(inquiry.transferred_at or inquiry.activated_at, inquiry.department_responded_at),
"color": "bg-amber-500",
})
if inquiry.responded_at:
stages.append({
"label": _("Response Sent to Inquirer"),
"timestamp": inquiry.responded_at,
"duration_from_prev": _format_duration(inquiry.department_responded_at or inquiry.transferred_at, inquiry.responded_at),
"color": "bg-cyan-500",
})
_resolved_at = getattr(inquiry, 'resolved_at', None)
if _resolved_at:
stages.append({
"label": _("Resolved"),
"timestamp": _resolved_at,
"duration_from_prev": _format_duration(inquiry.responded_at or inquiry.department_responded_at, _resolved_at),
"color": "bg-green-500",
})
_closed_at = getattr(inquiry, 'closed_at', None)
if _closed_at:
stages.append({
"label": _("Closed"),
"timestamp": _closed_at,
"duration_from_prev": _format_duration(_resolved_at or inquiry.responded_at, _closed_at),
"color": "bg-emerald-600",
})
return stages
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_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 not user.is_px_admin() and 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,
"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"
# OPTIMIZED: Added missing select_related fields and annotated counts
complaint_queryset = (
Complaint.objects.select_related(
"patient",
"hospital",
"department",
"staff",
"assigned_to",
"resolved_by",
"closed_by",
"resolution_survey",
"source",
"created_by",
"domain",
"category",
# ADD: Missing foreign keys that are accessed in template
"subcategory_obj",
"classification_obj",
"location",
"main_section",
"subsection",
)
.prefetch_related(
"attachments",
"updates__created_by",
"involved_departments__department",
"involved_departments__assigned_to",
"involved_staff__staff__department",
# ADD: Prefetch explanations with their attachments
Prefetch(
"explanations",
queryset=ComplaintExplanation.objects.select_related("staff")
.prefetch_related("attachments")
.order_by("-created_at"),
),
# ADD: Prefetch adverse actions with related data
Prefetch(
"adverse_actions",
queryset=ComplaintAdverseAction.objects.select_related("reported_by").prefetch_related(
"involved_staff"
),
),
)
.annotate(
# ADD: Annotate counts to avoid N+1 queries in template
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)
# Check access
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")
# OPTIMIZED: Use prefetched data instead of re-querying
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)
stage_timeline = _build_complaint_stage_timeline(complaint)
# Get related PX actions (using ContentType since PXAction uses GenericForeignKey)
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")
# Get assignable users
from apps.core.utils import get_assignable_users
assignable_users = get_assignable_users(complaint.hospital)
# Get departments for the complaint's hospital
hospital_departments = []
if complaint.hospital:
hospital_departments = Department.objects.filter(hospital=complaint.hospital, status="active").order_by("name")
# Check if overdue (only update if necessary)
if complaint.is_active_status and not complaint.is_overdue:
complaint.check_overdue()
# OPTIMIZED: Use prefetched explanations
explanations = complaint.explanations.all()
explanation = explanations.first() if explanations else None
# OPTIMIZED: Attachments are already prefetched
explanation_attachments = explanation.attachments.all() if explanation else []
# OPTIMIZED: Escalation targets - only query managers and direct reports
escalation_targets = []
default_escalation_target = None
if complaint.hospital:
# OPTIMIZED: Only query managers and potential escalation targets
# instead of ALL staff in the hospital
from django.db.models import Q
# Get potential escalation targets:
# 1. The staff's direct manager (if exists)
# 2. Department managers in the hospital
# 3. Hospital admins in the hospital
escalation_targets_qs = (
Staff.objects.filter(hospital=complaint.hospital, status="active", user__isnull=False, user__is_active=True)
.filter(
# Either is the staff's manager, or is a manager/admin
Q(id=complaint.staff.report_to.id if complaint.staff and complaint.staff.report_to else None)
| Q(user__groups__name__in=["Hospital Admin", "Department Manager"])
| Q(direct_reports__isnull=False)
)
.exclude(id=complaint.staff.id if complaint.staff else None)
.select_related("user", "department", "report_to")
.distinct()
.order_by("first_name", "last_name")
)
# Build list of escalation targets
for staff in escalation_targets_qs:
escalation_targets.append(
{
"staff": staff,
"has_user": True, # Already filtered for active user
"user_id": str(staff.user.id),
"is_manager": staff.direct_reports.exists(),
"is_line_manager": complaint.staff and complaint.staff.report_to == staff,
}
)
# Sort: Line manager first, then other managers, then others
escalation_targets.sort(
key=lambda x: (not x["is_line_manager"], not x["is_manager"], x["staff"].get_full_name())
)
# Set default to staff's line manager if exists
if complaint.staff and complaint.staff.report_to:
default_escalation_target = str(complaint.staff.report_to.id)
# OPTIMIZED: Use prefetched adverse actions
adverse_actions = complaint.adverse_actions.all()
from apps.rca.models import RootCauseAnalysis as RCA
complaint_ct = ContentType.objects.get_for_model(Complaint)
linked_rcas = RCA.objects.filter(
content_type=complaint_ct, object_id=complaint.pk, is_deleted=False
).select_related("assigned_to", "created_by")
context = {
"complaint": complaint,
"timeline": timeline,
"stage_timeline": stage_timeline,
"attachments": attachments,
"px_actions": px_actions,
"assignable_users": assignable_users,
"status_choices": ComplaintStatus.choices,
"base_layout": base_layout,
"source_user": source_user,
"can_edit": can_manage_complaint(user, complaint),
"is_active_status": complaint.is_active_status,
"ai_department_suggested": (
bool(complaint.department)
and not complaint.involved_departments.filter(department=complaint.department).exists()
),
"hospital_departments": hospital_departments,
"explanation": explanation,
"explanations": explanations,
"explanation_attachments": explanation_attachments,
"escalation_targets": escalation_targets,
"default_escalation_target": default_escalation_target,
"current_user": user,
"adverse_actions": adverse_actions,
"linked_rcas": linked_rcas,
"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")
),
}
_status_label_map = dict(ComplaintStatus.choices)
_valid_next = ComplaintService.VALID_STATUS_TRANSITIONS.get(complaint.status, [])
context["available_transitions"] = [(s, _status_label_map.get(s, s)) for s in _valid_next]
return render(request, "complaints/complaint_detail.html", context)
@login_required
@require_http_methods(["GET", "POST"])
def complaint_create(request):
"""Create new complaint with AI-powered classification"""
from apps.complaints.forms import ComplaintForm
# 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
# Generate unique reference number: CMP-YYYYMMDD-XXXXX
import uuid
from datetime import datetime
today = datetime.now().strftime("%Y%m%d")
random_suffix = str(uuid.uuid4().int)[:6]
complaint.reference_number = f"CMP-{today}-{random_suffix}"
complaint.save()
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 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(["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
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)
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 complaint to person
ComplaintService.assign(complaint, person, user, request=request)
# Send notification
if person.email:
NotificationService.send_email(
email=person.email,
subject=f"Complaint Assigned - {complaint.reference_number}",
message=f"You have been assigned to complaint #{complaint.reference_number}.",
html_message=f"""
<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>
""",
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.get(id=department_id, status="active")
except Department.DoesNotExist:
return JsonResponse({
"success": False,
"error": str(_("Department not found.")),
}, status=400)
# Create involved department record
involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create(
complaint=complaint,
department=department,
defaults={
"role": "secondary",
"added_by": user,
"forwarded_at": timezone.now(),
}
)
if not created:
involved_dept.forwarded_at = timezone.now()
involved_dept.save()
# Send notification to department champion
if department.respondent and department.respondent.user and department.respondent.user.email:
NotificationService.send_email(
email=department.respondent.user.email,
subject=f"Complaint Sent to Department - {complaint.reference_number}",
message=f"Complaint #{complaint.reference_number} has been sent to your department ({department.name}).",
html_message=f"""
<p>Complaint <strong>#{complaint.reference_number}</strong> has been sent to your department <strong>({department.name})</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()}/organizations/departments/{department.pk}/">View Department Page</a></p>
""",
related_object=complaint,
)
message = f"Complaint sent to {department.name}."
# Change status to contacted if active
if complaint.is_active_status and complaint.status in ("open", "in_progress"):
old_status = complaint.status
complaint.status = "contacted"
complaint.save(update_fields=["status"])
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="status_change",
message=f"Status changed from {old_status} to contacted - sent to {recipient_type}",
created_by=user,
)
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_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)
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)
from django.utils import timezone
complaint.satisfaction = satisfaction
if satisfaction:
complaint.satisfaction_set_at = timezone.now()
else:
complaint.satisfaction_set_at = None
complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"])
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 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
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,
)
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
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,
)
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_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)
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
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", "")
# Get the escalation target staff
escalate_to_staff = None
escalate_to_user = 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
except Staff.DoesNotExist:
pass
# If no staff selected or not found, default to staff's manager
if not escalate_to_staff and complaint.staff and complaint.staff.report_to:
escalate_to_staff = complaint.staff.report_to
if escalate_to_staff.user and escalate_to_staff.user.is_active:
escalate_to_user = escalate_to_staff.user
# Fallback: use escalation target resolver if still no target
if not escalate_to_user:
from apps.complaints.services.complaint_service import ComplaintService
fallback_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=complaint.staff)
if fallback_user:
escalate_to_user = fallback_user
reason += f" [fallback via {fallback_path}]"
# Mark as escalated and assign to selected user
complaint.escalated_at = timezone.now()
if escalate_to_user:
complaint.assigned_to = escalate_to_user
complaint.save(update_fields=["escalated_at", "assigned_to"])
# Create update with escalation details
escalation_message = f"Complaint escalated. Reason: {reason}"
if escalate_to_user:
escalation_message += f" Escalated to: {escalate_to_user.get_full_name()}"
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="escalation",
message=escalation_message,
created_by=request.user,
metadata={
"reason": reason,
"escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None,
"escalated_to_user_name": escalate_to_user.get_full_name() if escalate_to_user else None,
},
)
# Log audit
AuditService.log_event(
event_type="escalation",
description=f"Complaint escalated to {escalate_to_user.get_full_name() if escalate_to_user else 'manager'}",
user=request.user,
content_object=complaint,
metadata={
"reason": reason,
"escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None,
"escalated_to_user_name": escalate_to_user.get_full_name() if escalate_to_user else None,
},
)
# Send notification to the escalated user
if escalate_to_user and escalate_to_user.email:
from apps.notifications.services import NotificationService
try:
NotificationService.send_email(
email=escalate_to_user.email,
subject=f"Complaint Escalated - #{complaint.id}",
message=f"""
Dear {escalate_to_user.get_full_name()},
A complaint has been escalated to you for attention.
COMPLAINT DETAILS:
------------------
Reference: #{complaint.id}
Title: {complaint.title}
Severity: {complaint.get_severity_display()}
Priority: {complaint.get_priority_display()}
ESCALATION REASON:
------------------
{reason}
Please review and take necessary action.
View complaint: https://{request.get_host()}/complaints/{complaint.id}/
---
This is an automated message from PX360 Complaint Management System.
""",
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{f' to {escalate_to_user.get_full_name()}' if escalate_to_user else ''}.",
)
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
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()):
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",
"main_section",
"subsection",
"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()):
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()):
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", "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_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()
# Apply filters
status_filter = request.GET.get("status")
if status_filter:
queryset = queryset.filter(status=status_filter)
category_filter = request.GET.get("category")
if category_filter:
queryset = queryset.filter(category=category_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
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(subject__icontains=search_query)
| Q(message__icontains=search_query)
| Q(contact_name__icontains=search_query)
| Q(contact_email__icontains=search_query)
)
# 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.object_list,
"stats": stats,
"departments": departments,
"filters": request.GET,
}
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", "location", "main_section", "subsection",
"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_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:
hospital_departments = Department.objects.filter(hospital=inquiry.hospital, status="active").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"
)
context = {
"inquiry": inquiry,
"timeline": timeline,
"stage_timeline": stage_timeline,
"attachments": attachments,
"assignable_users": assignable_users,
"hospital_departments": hospital_departments,
"status_choices": status_choices,
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
"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]
)
),
"can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(),
"can_send_reminder": user.is_px_admin() or user.is_hospital_admin(),
"base_layout": base_layout,
"source_user": source_user,
"linked_rcas": linked_rcas,
}
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()):
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:
NotificationService.send_email(
to_email=staff.email,
subject=f"Inquiry Response Requested - #{inquiry.reference_number or inquiry.id}",
template_name="emails/inquiry_explanation_request",
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,
},
)
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(["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"
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()
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
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", "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()):
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 = {}
if inquiry.location:
initial_data["location"] = inquiry.location_id
if inquiry.main_section:
initial_data["main_section"] = inquiry.main_section_id
if inquiry.subsection:
initial_data["subsection"] = inquiry.subsection_id
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()):
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.save(update_fields=["assigned_to", "assigned_at", "status"])
# 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)
)
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()):
messages.error(request, "You don't have permission to change inquiry status.")
return 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 redirect("inquiries:inquiry_detail", pk=pk)
old_status = inquiry.status
inquiry.status = new_status
# Handle status-specific logic
if new_status == "resolved" and not inquiry.response:
messages.error(request, "Please add a response before resolving.")
return redirect("inquiries:inquiry_detail", pk=pk)
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 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)
):
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]
)
)
if not can_respond:
messages.error(request, "You don't have permission to respond to inquiries.")
return redirect("inquiries:inquiry_detail", pk=pk)
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_detail", pk=pk)
inquiry.response = response
inquiry.response_en = response_en
inquiry.response_ar = response_ar
inquiry.responded_at = timezone.now()
inquiry.responded_by = request.user
inquiry.status = "resolved"
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
if inquiry.contact_phone:
sms_text = response_en if response_en else response_ar
if len(sms_text) > 200:
sms_text = sms_text[:197] + "..."
NotificationService.send_sms(
phone=inquiry.contact_phone,
message=f"PX360: Your inquiry #{inquiry.reference_number} has been responded to. {sms_text}",
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_parts = []
if response_en:
email_body_parts.append(f"Response (English):\n\n{response_en}")
if response_ar:
email_body_parts.append(f"الرد (العربية):\n\n{response_ar}")
email_body = "\n\n---\n\n".join(email_body_parts)
email_body += f"\n\n---\nReference: {inquiry.reference_number}\nHospital: {inquiry.hospital.name}\n\nThis is an automated message from PX 360."
NotificationService.send_email(
email=inquiry.contact_email,
subject=email_subject,
message=email_body,
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()
or user.is_px_management()
):
messages.error(request, _("You don't have permission to transfer inquiries to departments."))
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.get(pk=department_id, status="active")
except Department.DoesNotExist:
messages.error(request, _("Department not found."))
return redirect("inquiries:inquiry_detail", pk=pk)
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
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.status = "contacted"
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:
from apps.notifications.settings_service import NotificationServiceWithSettings
NotificationServiceWithSettings.send_inquiry_department_assigned(
department, inquiry, context_note_en=note_en, context_note_ar=note_ar,
recipient_type=recipient_type,
)
if department.respondent and department.respondent.user and department.respondent.user.email:
from apps.notifications.services import NotificationService
NotificationService.send_email(
email=department.respondent.user.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. Subject: {inquiry.subject}. Please submit your response before the deadline: {inquiry.dept_response_sla_due_at}",
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_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
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()
or user.is_px_management()
):
return JsonResponse({
"success": False,
"error": str(_("You don't have permission to send this inquiry.")),
}, status=403)
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"""
<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>
""",
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.get(pk=department_id, status="active")
except Department.DoesNotExist:
return JsonResponse({
"success": False,
"error": str(_("Department not found.")),
}, 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
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
message = f"Inquiry sent to {department.get_localized_name()}."
# Change status to contacted if open
if inquiry.status in ("open",):
inquiry.status = "contacted"
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,
)
# Send department notification if applicable
if recipient_type == "department":
try:
from apps.notifications.settings_service import NotificationServiceWithSettings
NotificationServiceWithSettings.send_inquiry_department_assigned(
department, inquiry, context_note_en=note, context_note_ar="",
)
except Exception as e:
logger.warning(f"Failed to send department notification: {e}")
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
))
):
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.dept_response_acceptance_status = "pending"
inquiry.dept_response_accepted_by = None
inquiry.dept_response_accepted_at = None
inquiry.dept_response_acceptance_notes = ""
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.")
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_review_dept_response(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()):
messages.error(request, "You don't have permission to review department responses.")
return redirect("inquiries:inquiry_detail", pk=pk)
if not inquiry.department_responded_at:
messages.error(request, "No department response to review.")
return redirect("inquiries:inquiry_detail", pk=pk)
status = request.POST.get("acceptance_status")
if status not in ("acceptable", "not_acceptable"):
messages.error(request, "Invalid acceptance status.")
return redirect("inquiries:inquiry_detail", pk=pk)
notes = request.POST.get("acceptance_notes", "").strip()
inquiry.dept_response_acceptance_status = status
inquiry.dept_response_accepted_by = user
inquiry.dept_response_accepted_at = timezone.now()
inquiry.dept_response_acceptance_notes = notes
inquiry.save(
update_fields=[
"dept_response_acceptance_status",
"dept_response_accepted_by",
"dept_response_accepted_at",
"dept_response_acceptance_notes",
]
)
InquiryUpdate.objects.create(
inquiry=inquiry,
update_type="note",
message=f"Department response marked as {status} by {user.get_full_name()}. {notes}",
created_by=user,
)
AuditService.log_event(
event_type="inquiry_dept_response_review",
description=f"Department response for inquiry {inquiry.reference_number} marked as {status}",
user=user,
content_object=inquiry,
)
messages.success(request, f"Department response marked as {status}.")
return redirect("inquiries:inquiry_detail", pk=pk)
@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()):
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")
try:
from apps.notifications.services import NotificationService
recipients = []
if dept.respondent and dept.respondent.user and dept.respondent.user.email:
recipients.append(dept.respondent.user)
for recipient in recipients:
NotificationService.send_email(
email=recipient.email,
subject=f"Reminder: Inquiry #{inquiry.reference_number} - Response Required",
message=f"This is a reminder that inquiry #{inquiry.reference_number} is awaiting your department's response. Please submit your response as soon as possible.",
related_object=inquiry,
)
if reminder_type == "first":
inquiry.dept_response_reminder_sent_at = timezone.now()
else:
inquiry.dept_response_second_reminder_sent_at = timezone.now()
inquiry.save()
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):
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.status = "contacted_no_response"
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 in ("open", "contacted_no_response"):
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", "contacted_no_response"):
inquiry.status = "contacted"
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")
location_id = request.POST.get("location")
main_section_id = request.POST.get("main_section")
subsection_id = request.POST.get("subsection")
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_id:
errors.append(_("Location is required"))
if not main_section_id:
errors.append(_("Main section 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)
# Get location hierarchy objects
from apps.organizations.models import Location, MainSection, SubSection
location = Location.objects.get(id=location_id)
main_section = MainSection.objects.get(id=main_section_id)
subsection = None
if subsection_id:
subsection = SubSection.objects.get(internal_id=subsection_id)
# Generate unique reference number: CMP-YYYYMMDD-XXXXX
import uuid
from datetime import datetime
today = datetime.now().strftime("%Y%m%d")
random_suffix = str(uuid.uuid4().int)[:6]
reference_number = f"CMP-{today}-{random_suffix}"
# Create complaint with location hierarchy and all form fields
complaint = Complaint.objects.create(
patient=None, # No patient record for public submissions
hospital=hospital,
department=None, # AI will determine this
title="Complaint", # AI will generate title
description=complaint_details,
severity="medium", # Default, AI will update
priority="medium", # Default, AI will update
status="open", # Start as open
complaint_source_type=ComplaintSourceType.EXTERNAL, source=PXSource.objects.filter(name_en="Public Form").first(),
reference_number=reference_number,
# Location hierarchy (FK relationships)
location=location,
main_section=main_section,
subsection=subsection,
# Complainant information
contact_name=complainant_name,
contact_phone=mobile_number,
contact_email=email,
# Store additional information in metadata
metadata={
"relation_to_patient": relation_to_patient,
"patient_name": patient_name,
"national_id": national_id,
"incident_date": incident_date,
"staff_name": staff_name,
"expected_result": expected_result,
},
)
# Create initial update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message="Complaint submitted via public form. AI analysis running in background.",
)
# Trigger AI analysis in the background using Celery
from .tasks import analyze_complaint_with_ai, notify_staff_new_item
analyze_complaint_with_ai.delay(str(complaint.id))
notify_staff_new_item.delay("complaint", str(complaint.id))
# 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_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
"""
complaint = None
error_message = None
reference_number = request.GET.get("reference", "").strip()
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", "location", "main_section", "subsection")
.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.")
elif reference_number:
# GET request with reference parameter
try:
complaint = (
Complaint.objects.select_related("hospital", "department", "location", "main_section", "subsection")
.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.")
public_status = None
public_updates = []
if complaint:
public_status = complaint.public_status
public_updates = list(
complaint.updates.filter(update_type__in=["status_change", "resolution", "communication"]).order_by(
"-created_at"
)
)
_status_map = {
"open": str(_("Received")),
"in_progress": str(_("In Progress")),
"partially_resolved": str(_("In Progress")),
"contacted": str(_("In Progress")),
"contacted_no_response": str(_("In Progress")),
"resolved": str(_("Resolved")),
"closed": str(_("Closed")),
"cancelled": str(_("Cancelled")),
}
for update in public_updates:
if update.comments:
for internal, public_label in _status_map.items():
update.comments = update.comments.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,
"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")
hospital_id = request.POST.get("hospital")
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)
import uuid
from datetime import datetime
today = datetime.now().strftime("%Y%m%d")
random_suffix = str(uuid.uuid4().int)[:6]
reference_number = f"INQ-{today}-{random_suffix}"
inquiry = Inquiry.objects.create(
patient=None,
hospital=hospital,
subject=subject,
message=message,
category=category,
contact_name=name,
contact_phone=phone,
contact_email=email,
status="open",
reference_number=reference_number,
is_outgoing=False,
)
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))
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, InquiryUpdate
inquiry = None
public_updates = []
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"],
}
public_updates = list(
InquiryUpdate.objects.filter(inquiry=inquiry)
.select_related("created_by")
.order_by("-created_at")[:20]
)
else:
error_message = _("No inquiry found with this reference number. Please check and try again.")
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"),
"updates": [
{
"type": u.update_type,
"message": u.message,
"date": u.created_at.strftime("%Y-%m-%d %H:%M"),
}
for u in public_updates
],
})
else:
return JsonResponse({"success": False, "error": "Inquiry not found"})
return render(request, "complaints/public_inquiry_track.html", {
"inquiry": inquiry,
"public_status": public_status,
"public_updates": public_updates,
"error_message": error_message,
"reference_number": reference,
})
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)
severity_based_configs = sla_configs.filter(source__isnull=True)
context = {
"hospital": hospital,
"source_based_configs": source_based_configs,
"severity_based_configs": severity_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 escalation_rule_list(request):
"""
Escalation Rules list view with filters.
Allows Hospital Admins and PX Admins to manage escalation rules.
"""
from .models import EscalationRule
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to manage escalation rules.")
return redirect("accounts:settings")
# Base queryset
queryset = EscalationRule.objects.select_related("hospital", "escalate_to_user").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)
escalation_level_filter = request.GET.get("escalation_level")
if escalation_level_filter:
queryset = queryset.filter(escalation_level=escalation_level_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,
"escalation_rules": page_obj.object_list,
"filters": request.GET,
}
return render(request, "complaints/escalation_rule_list.html", context)
@login_required
@require_http_methods(["GET", "POST"])
def escalation_rule_create(request):
"""
Create new escalation rule.
"""
from .models import EscalationRule
from .forms import EscalationRuleForm
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to create escalation rules.")
return redirect("accounts:settings")
if request.method == "POST":
form = EscalationRuleForm(request.POST, request=request)
if form.is_valid():
escalation_rule = form.save()
# Log audit
AuditService.log_event(
event_type="escalation_rule_created",
description=f"Escalation rule created: {escalation_rule}",
user=request.user,
content_object=escalation_rule,
metadata={
"hospital": str(escalation_rule.hospital),
"name": escalation_rule.name,
"escalation_level": escalation_rule.escalation_level,
},
)
messages.success(request, "Escalation rule created successfully.")
return redirect("complaints:escalation_rule_list")
else:
messages.error(request, "Please correct the errors below.")
else:
form = EscalationRuleForm(request=request)
context = {
"form": form,
"title": "Create Escalation Rule",
"action": "Create",
}
return render(request, "complaints/escalation_rule_form.html", context)
@login_required
@require_http_methods(["GET", "POST"])
def escalation_rule_edit(request, pk):
"""
Edit existing escalation rule.
"""
from .models import EscalationRule
from .forms import EscalationRuleForm
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to edit escalation rules.")
return redirect("accounts:settings")
escalation_rule = get_object_or_404(EscalationRule, pk=pk)
# Check if user can edit this rule
if not user.is_px_admin() and escalation_rule.hospital != user.hospital:
messages.error(request, "You don't have permission to edit this escalation rule.")
return redirect("complaints:escalation_rule_list")
if request.method == "POST":
form = EscalationRuleForm(request.POST, request=request, instance=escalation_rule)
if form.is_valid():
escalation_rule = form.save()
# Log audit
AuditService.log_event(
event_type="escalation_rule_updated",
description=f"Escalation rule updated: {escalation_rule}",
user=request.user,
content_object=escalation_rule,
metadata={
"hospital": str(escalation_rule.hospital),
"name": escalation_rule.name,
"escalation_level": escalation_rule.escalation_level,
},
)
messages.success(request, "Escalation rule updated successfully.")
return redirect("complaints:escalation_rule_list")
else:
messages.error(request, "Please correct the errors below.")
else:
form = EscalationRuleForm(request=request, instance=escalation_rule)
context = {
"form": form,
"escalation_rule": escalation_rule,
"title": "Edit Escalation Rule",
"action": "Update",
}
return render(request, "complaints/escalation_rule_form.html", context)
@login_required
@require_http_methods(["POST"])
def escalation_rule_delete(request, pk):
"""
Delete escalation rule.
"""
from .models import EscalationRule
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to delete escalation rules.")
return redirect("accounts:settings")
escalation_rule = get_object_or_404(EscalationRule, pk=pk)
# Check if user can delete this rule
if not user.is_px_admin() and escalation_rule.hospital != user.hospital:
messages.error(request, "You don't have permission to delete this escalation rule.")
return redirect("complaints:escalation_rule_list")
escalation_rule.delete()
# Log audit
AuditService.log_event(
event_type="escalation_rule_deleted",
description=f"Escalation rule deleted: {escalation_rule}",
user=request.user,
metadata={
"hospital": str(escalation_rule.hospital),
"name": escalation_rule.name,
},
)
messages.success(request, "Escalation rule deleted successfully.")
return redirect("complaints:escalation_rule_list")
@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()):
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()):
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()):
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()):
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(),
forwarded_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()
# Mark as forwarded to department (for tracking pending responses)
if not involved_dept.forwarded_at:
involved_dept.forwarded_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,
},
)
messages.success(
request,
_("Department '%(dept)s' added successfully as %(role)s.")
% {"dept": involved_dept.department.name, "role": involved_dept.get_role_display()},
)
return redirect("complaints:complaint_detail", pk=complaint.pk)
else:
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)
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()
if not response_notes:
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
involved_dept.response_submitted = True
involved_dept.response_submitted_at = timezone.now()
involved_dept.save()
# Log the update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Response submitted by {involved_dept.department.name}",
created_by=user,
)
# Send notification to complaint assigned_to
if complaint.assigned_to and complaint.assigned_to.email:
from apps.notifications.services import NotificationService
NotificationService.send_email(
complaint.assigned_to.email,
subject=f"Department Response Received - {complaint.reference_number}",
message=f"A response has been submitted by {involved_dept.department.name} for complaint {complaint.reference_number}.",
html_message=f"""
<p>A response has been submitted by <strong>{involved_dept.department.name}</strong>
for complaint <strong>{complaint.reference_number}</strong>.</p>
<p><a href="https://{request.get_host()}/complaints/{complaint.pk}/">View Complaint</a></p>
""",
)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({
"success": True,
"message": str(_("Department response submitted successfully.")),
})
messages.success(request, _("Department response submitted successfully."))
return redirect("complaints:complaint_detail", pk=complaint.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,
},
)
messages.success(
request,
_("Staff member '%(staff)s' added successfully as %(role)s.")
% {"staff": involved_staff.staff, "role": involved_staff.get_role_display()},
)
return redirect("complaints:complaint_detail", pk=complaint.pk)
else:
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)
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, is_active=True),
},
)
# 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, is_active=True)
if complaint.hospital
else Staff.objects.filter(is_active=True),
}
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, is_active=True)
if complaint.hospital
else Staff.objects.filter(is_active=True),
"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="external",
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:index")
# Base queryset
queryset = GovernmentTicket.objects.select_related("source", "location", "main_section", "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(main_section_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", "location", "main_section", "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
from django.urls import reverse
import urllib.parse
params = {
"source": ticket.source_id,
"complaint_source_type": "external",
"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 Location, MainSection, SubSection
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 = Location.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 = MainSection.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 = SubSection.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 "",
location=ticket_location,
main_section=ticket_main_section,
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", "location", "main_section", "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(main_section_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.location.name_en if ticket.location else "",
"القسم الرئيسي": ticket.main_section.name_en if ticket.main_section else "",
"القسم الفرعي": ticket.subsection.name_en if ticket.subsection 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()):
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()):
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()):
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()):
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,
})