HH/apps/complaints/views.py
2026-07-05 14:49:45 +03:00

5505 lines
224 KiB
Python

"""
Complaints views and viewsets
"""
import logging
from django.contrib.auth.decorators import login_required
from django.conf import settings
from django.db.models import Q
from django.shortcuts import get_object_or_404, render, redirect
from django.urls import reverse
from django.utils import timezone
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.core.services import AuditService
from .models import (
Complaint,
ComplaintAttachment,
ComplaintExplanation,
ComplaintMeeting,
ComplaintPRInteraction,
ComplaintStatus,
ComplaintUpdate,
InvestigationStatus,
Inquiry,
)
from .serializers import (
ComplaintAttachmentSerializer,
ComplaintListSerializer,
ComplaintMeetingSerializer,
ComplaintPRInteractionSerializer,
ComplaintSerializer,
ComplaintUpdateSerializer,
InquirySerializer,
)
from .services.complaint_service import ComplaintService, ComplaintServiceError
logger = logging.getLogger(__name__)
def map_complaint_category_to_action_category(complaint_category_code):
"""
Map complaint category code to PX Action category.
Provides intelligent mapping from complaint categories to PX Action categories.
Returns 'other' as fallback if no match found.
"""
if not complaint_category_code:
return "other"
mapping = {
# Clinical issues
"clinical": "clinical_quality",
"medical": "clinical_quality",
"diagnosis": "clinical_quality",
"treatment": "clinical_quality",
"medication": "clinical_quality",
"care": "clinical_quality",
# Safety issues
"safety": "patient_safety",
"risk": "patient_safety",
"incident": "patient_safety",
"infection": "patient_safety",
"harm": "patient_safety",
# Service quality
"service": "service_quality",
"communication": "service_quality",
"wait": "service_quality",
"response": "service_quality",
"customer_service": "service_quality",
"timeliness": "service_quality",
"waiting_time": "service_quality",
# Staff behavior
"staff": "staff_behavior",
"behavior": "staff_behavior",
"attitude": "staff_behavior",
"professionalism": "staff_behavior",
"rude": "staff_behavior",
"respect": "staff_behavior",
# Facility
"facility": "facility",
"environment": "facility",
"cleanliness": "facility",
"equipment": "facility",
"infrastructure": "facility",
"parking": "facility",
"accessibility": "facility",
# Process
"process": "process_improvement",
"administrative": "process_improvement",
"billing": "process_improvement",
"procedure": "process_improvement",
"workflow": "process_improvement",
"registration": "process_improvement",
"appointment": "process_improvement",
}
# Try exact match first
category_lower = complaint_category_code.lower()
if category_lower in mapping:
return mapping[category_lower]
# Try partial match (contains the keyword)
for keyword, action_category in mapping.items():
if keyword in category_lower:
return action_category
# Fallback to 'other'
return "other"
class ComplaintViewSet(viewsets.ModelViewSet):
"""
ViewSet for Complaints with workflow actions.
Permissions:
- All authenticated users can view complaints
- PX Admins and Hospital Admins can create/manage complaints
"""
queryset = Complaint.objects.all()
permission_classes = [IsAuthenticated]
filterset_fields = [
"status",
"severity",
"priority",
"category",
"source",
"hospital",
"department",
"staff",
"assigned_to",
"is_overdue",
"hospital__organization",
]
search_fields = [
"title",
"description",
"reference_number",
"patient__mrn",
"patient__first_name",
"patient__last_name",
]
ordering_fields = ["created_at", "due_at", "severity"]
ordering = ["-created_at"]
def get_serializer_class(self):
"""Use simplified serializer for list view"""
if self.action == "list":
return ComplaintListSerializer
return ComplaintSerializer
def get_queryset(self):
"""Filter complaints based on user role"""
queryset = (
super()
.get_queryset()
.select_related(
"patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by", "created_by"
)
.prefetch_related("attachments", "updates")
)
user = self.request.user
if user.is_px_admin():
if hasattr(self.request, "tenant_hospital") and self.request.tenant_hospital:
return queryset.filter(hospital=self.request.tenant_hospital)
return queryset
# Source Users see ONLY complaints THEY created
if hasattr(user, "source_user_profile") and user.source_user_profile.exists():
return queryset.filter(created_by=user)
# Patients see ONLY their own complaints (if they have user accounts)
# This assumes patients can have user accounts linked via patient.user
if hasattr(user, "patient_profile"):
return queryset.filter(patient__user=user)
# Hospital Admins see complaints for their hospital
if user.is_hospital_admin() and user.hospital:
return queryset.filter(hospital=user.hospital)
# Department Managers see complaints for their department
if user.is_department_manager() and user.department:
return queryset.filter(department=user.department)
# Others see complaints for their hospital
if user.hospital:
return queryset.filter(hospital=user.hospital)
return queryset.none()
def get_object(self):
"""
Override get_object to allow PX Admins to access complaints
for specific actions (request_explanation, resend_explanation, send_notification, assignable_admins).
"""
queryset = self.filter_queryset(self.get_queryset())
# PX Admins can access any complaint for specific actions
if self.request.user.is_px_admin() and self.action in [
"request_explanation",
"resend_explanation",
"send_notification",
"assignable_admins",
"escalate_explanation",
"review_explanation",
]:
# Bypass queryset filtering and get directly by pk
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_value = self.kwargs[lookup_url_kwarg]
return get_object_or_404(Complaint, pk=lookup_value)
# Normal behavior for other users/actions
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
obj = get_object_or_404(queryset, **filter_kwargs)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
def perform_create(self, serializer):
"""Log complaint creation and trigger AI analysis"""
complaint = serializer.save(created_by=self.request.user)
ComplaintService.post_create_hooks(complaint, self.request.user, request=self.request)
@action(detail=True, methods=["post"])
def activate(self, request, pk=None):
"""Activate complaint by assigning it to current user."""
complaint = self.get_object()
try:
result = ComplaintService.activate(complaint, request.user, request=request)
except ComplaintServiceError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{
"message": "Complaint activated successfully",
"assigned_to": {
"id": str(request.user.id),
"name": request.user.get_full_name(),
"roles": request.user.get_role_names(),
},
"assigned_at": complaint.assigned_at.isoformat(),
"status": complaint.status,
}
)
@action(detail=True, methods=["post"])
def assign(self, request, pk=None):
"""Assign complaint to user (PX Admin or Hospital Admin)"""
complaint = self.get_object()
user_id = request.data.get("user_id")
if not user_id:
return Response({"error": "user_id is required"}, status=status.HTTP_400_BAD_REQUEST)
from apps.accounts.models import User
try:
target_user = User.objects.get(id=user_id)
ComplaintService.assign(complaint, target_user, request.user, request=request)
except User.DoesNotExist:
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
except ComplaintServiceError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"message": "Complaint assigned successfully"})
@action(detail=True, methods=["get"])
def assignable_admins(self, request, pk=None):
"""
Get assignable admins (PX Admins and Hospital Admins) for this complaint.
Returns list of all PX Admins and Hospital Admins.
Supports searching by name.
"""
complaint = self.get_object()
# Check if user has permission to assign admins
if not request.user.is_px_admin():
return Response(
{"error": "Only PX Admins can assign complaints to admins"}, status=status.HTTP_403_FORBIDDEN
)
from apps.accounts.models import User
# Get search parameter
search = request.query_params.get("search", "").strip()
# Simple query - get all PX Admins and Hospital Admins
base_query = Q(groups__name="PX Admin") | Q(groups__name="Hospital Admin")
queryset = (
User.objects.filter(base_query, is_active=True)
.select_related("hospital")
.prefetch_related("groups")
.order_by("first_name", "last_name")
)
# Search by name or email if provided
if search:
queryset = queryset.filter(
Q(first_name__icontains=search) | Q(last_name__icontains=search) | Q(email__icontains=search)
)
# Serialize
admins_list = []
for user in queryset:
roles = user.get_role_names()
role_display = ", ".join(roles)
admins_list.append(
{
"id": str(user.id),
"name": user.get_full_name(),
"email": user.email,
"roles": roles,
"role_display": role_display,
"hospital": user.hospital.name if user.hospital else None,
"is_px_admin": user.is_px_admin(),
"is_hospital_admin": user.is_hospital_admin(),
}
)
return Response(
{
"complaint_id": str(complaint.id),
"hospital_id": str(complaint.hospital.id),
"hospital_name": complaint.hospital.name,
"current_assignee": {
"id": str(complaint.assigned_to.id),
"name": complaint.assigned_to.get_full_name(),
"email": complaint.assigned_to.email,
"roles": complaint.assigned_to.get_role_names(),
}
if complaint.assigned_to
else None,
"admin_count": len(admins_list),
"admins": admins_list,
}
)
@action(detail=True, methods=["post"])
def change_status(self, request, pk=None):
"""Change complaint status"""
complaint = self.get_object()
try:
ComplaintService.change_status(
complaint,
request.data.get("status", ""),
request.user,
request=request,
note=request.data.get("note", ""),
resolution=request.data.get("resolution", ""),
resolution_category=request.data.get("resolution_category", ""),
)
except ComplaintServiceError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"message": "Status updated successfully"})
@action(detail=True, methods=["post"])
def add_note(self, request, pk=None):
"""Add note to complaint"""
complaint = self.get_object()
note = request.data.get("note")
try:
update = ComplaintService.add_note(complaint, note, request.user, request=request)
except ComplaintServiceError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
serializer = ComplaintUpdateSerializer(update)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["get"])
def staff_suggestions(self, request, pk=None):
"""
Get staff matching suggestions for a complaint.
Returns potential staff matches from AI analysis,
allowing PX Admins to review and select correct staff.
"""
complaint = self.get_object()
# Check if user is PX Admin
if not request.user.is_px_admin():
return Response({"error": "Only PX Admins can access staff suggestions"}, status=status.HTTP_403_FORBIDDEN)
# Get AI analysis metadata
ai_analysis = complaint.metadata.get("ai_analysis", {})
staff_matches = ai_analysis.get("staff_matches", [])
extracted_name = ai_analysis.get("extracted_staff_name", "")
needs_review = ai_analysis.get("needs_staff_review", False)
matched_staff_id = ai_analysis.get("matched_staff_id")
return Response(
{
"extracted_name": extracted_name,
"staff_matches": staff_matches,
"current_staff_id": matched_staff_id,
"needs_staff_review": needs_staff_review,
"staff_match_count": len(staff_matches),
}
)
@action(detail=True, methods=["get"])
def hospital_staff(self, request, pk=None):
"""
Get all staff from complaint's hospital for manual selection.
Allows PX Admins to manually select staff.
Supports filtering by department.
"""
complaint = self.get_object()
# Check if user is PX Admin
if not request.user.is_px_admin():
return Response(
{"error": "Only PX Admins can access hospital staff list"}, status=status.HTTP_403_FORBIDDEN
)
from apps.organizations.models import Staff
# Get query params
department_id = request.query_params.get("department_id")
search = request.query_params.get("search", "").strip()
# Build query
queryset = Staff.objects.filter(hospital=complaint.hospital, status="active").select_related("department")
# Filter by department if specified
if department_id:
queryset = queryset.filter(department_id=department_id)
# Search by name if provided
if search:
queryset = queryset.filter(
Q(first_name__icontains=search)
| Q(last_name__icontains=search)
| Q(first_name_ar__icontains=search)
| Q(last_name_ar__icontains=search)
| Q(job_title__icontains=search)
)
# Order by department and name
queryset = queryset.order_by("department__name", "first_name", "last_name")
# Serialize
staff_list = []
for staff in queryset:
staff_list.append(
{
"id": str(staff.id),
"name_en": f"{staff.first_name} {staff.last_name}",
"name_ar": f"{staff.first_name_ar} {staff.last_name_ar}"
if staff.first_name_ar and staff.last_name_ar
else "",
"job_title": staff.job_title,
"specialization": staff.specialization,
"department": staff.department.name if staff.department else None,
"department_id": str(staff.department.id) if staff.department else None,
}
)
return Response(
{
"hospital_id": str(complaint.hospital.id),
"hospital_name": complaint.hospital.name,
"staff_count": len(staff_list),
"staff": staff_list,
}
)
@action(detail=True, methods=["post"])
def assign_staff(self, request, pk=None):
"""
Manually assign staff to a complaint.
Allows PX Admins to assign specific staff member,
especially when AI matching is ambiguous.
"""
complaint = self.get_object()
# Check if complaint is in active status
if not complaint.is_active_status:
return Response(
{
"error": f"Cannot assign staff to complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved."
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user is PX Admin
if not request.user.is_px_admin():
return Response(
{"error": "Only PX Admins can assign staff to complaints"}, status=status.HTTP_403_FORBIDDEN
)
staff_id = request.data.get("staff_id")
reason = request.data.get("reason", "")
if not staff_id:
return Response({"error": "staff_id is required"}, status=status.HTTP_400_BAD_REQUEST)
from apps.organizations.models import Staff
try:
staff = Staff.objects.get(id=staff_id)
except Staff.DoesNotExist:
return Response({"error": "Staff not found"}, status=status.HTTP_404_NOT_FOUND)
# Check staff belongs to same hospital
if staff.hospital != complaint.hospital:
return Response(
{"error": "Staff does not belong to complaint hospital"}, status=status.HTTP_400_BAD_REQUEST
)
# Update complaint
old_staff_id = str(complaint.staff.id) if complaint.staff else None
complaint.staff = staff
# Auto-set department from staff
complaint.department = staff.department
complaint.save(update_fields=["staff", "department"])
# Update metadata to clear review flag
if not complaint.metadata:
complaint.metadata = {}
if "ai_analysis" in complaint.metadata:
complaint.metadata["ai_analysis"]["needs_staff_review"] = False
complaint.metadata["ai_analysis"]["staff_manually_assigned"] = True
complaint.metadata["ai_analysis"]["staff_assigned_by"] = str(request.user.id)
complaint.metadata["ai_analysis"]["staff_assigned_at"] = timezone.now().isoformat()
complaint.metadata["ai_analysis"]["staff_assignment_reason"] = reason
complaint.save(update_fields=["metadata"])
# Create update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="assignment",
message=f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title}). {reason}"
if reason
else f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title})",
created_by=request.user,
metadata={"old_staff_id": old_staff_id, "new_staff_id": str(staff.id), "manual_assignment": True},
)
# Log audit
AuditService.log_from_request(
event_type="staff_assigned",
description=f"Staff {staff.first_name} {staff.last_name} manually assigned to complaint by {request.user.get_full_name()}",
request=request,
content_object=complaint,
metadata={"old_staff_id": old_staff_id, "new_staff_id": str(staff.id), "reason": reason},
)
return Response(
{
"message": "Staff assigned successfully",
"staff_id": str(staff.id),
"staff_name": f"{staff.first_name} {staff.last_name}",
}
)
@action(detail=True, methods=["post"])
def change_department(self, request, pk=None):
"""Change complaint department"""
complaint = self.get_object()
department_id = request.data.get("department_id")
if not department_id:
return Response({"error": "department_id is required"}, status=status.HTTP_400_BAD_REQUEST)
from apps.organizations.models import Department
try:
department = Department.objects.get(id=department_id)
ComplaintService.change_department(complaint, department, request.user, request=request)
except Department.DoesNotExist:
return Response({"error": "Department not found"}, status=status.HTTP_404_NOT_FOUND)
except ComplaintServiceError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{
"message": "Department changed successfully",
"department_id": str(department.id),
"department_name": department.name,
}
)
@action(detail=True, methods=["post"])
def create_action_from_ai(self, request, pk=None):
"""Create PX Action using AI service to generate action details from complaint"""
complaint = self.get_object()
# Use AI service to generate action data
from apps.core.ai_service import AIService
try:
action_data = AIService.create_px_action_from_complaint(complaint)
except Exception as e:
return Response(
{"error": f"Failed to generate action data: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Get optional assigned_to from request (AI doesn't assign by default)
assigned_to_id = request.data.get("assigned_to")
assigned_to = None
if assigned_to_id:
from apps.accounts.models import User
try:
assigned_to = User.objects.get(id=assigned_to_id)
except User.DoesNotExist:
return Response({"error": "Assigned user not found"}, status=status.HTTP_404_NOT_FOUND)
# Create PX Action
from apps.px_action_center.models import PXAction, PXActionLog
from django.contrib.contenttypes.models import ContentType
complaint_content_type = ContentType.objects.get_for_model(Complaint)
action = PXAction.objects.create(
source_type="complaint",
content_type=complaint_content_type,
object_id=complaint.id,
title=action_data["title"],
description=action_data["description"],
hospital=complaint.hospital,
department=complaint.department,
category=action_data["category"],
priority=action_data["priority"],
severity=action_data["severity"],
assigned_to=assigned_to,
status="open",
metadata={
"source_complaint_id": str(complaint.id),
"source_complaint_title": complaint.title,
"ai_generated": True,
"ai_reasoning": action_data.get("reasoning", ""),
"created_from_ai_suggestion": True,
},
)
# Create action log entry
PXActionLog.objects.create(
action=action,
log_type="note",
message=f"Action generated by AI for complaint: {complaint.title}",
created_by=request.user,
metadata={
"complaint_id": str(complaint.id),
"ai_generated": True,
"category": action_data["category"],
"priority": action_data["priority"],
"severity": action_data["severity"],
},
)
# Create complaint update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"PX Action created from AI-generated suggestion (Action #{action.id}) - {action_data['category']}",
created_by=request.user,
metadata={"action_id": str(action.id), "category": action_data["category"]},
)
# Log audit
AuditService.log_from_request(
event_type="action_created_from_ai",
description=f"PX Action created from AI analysis for complaint: {complaint.title}",
request=request,
content_object=action,
metadata={
"complaint_id": str(complaint.id),
"category": action_data["category"],
"priority": action_data["priority"],
"severity": action_data["severity"],
"ai_reasoning": action_data.get("reasoning", ""),
},
)
return Response(
{
"action_id": str(action.id),
"message": "Action created successfully from AI analysis",
"action_data": {
"title": action_data["title"],
"category": action_data["category"],
"priority": action_data["priority"],
"severity": action_data["severity"],
},
},
status=status.HTTP_201_CREATED,
)
@action(detail=True, methods=["post"])
def send_notification(self, request, pk=None):
"""
Send email notification to staff member or department head.
Sends complaint notification with AI-generated summary (editable by user).
Logs the operation to NotificationLog and ComplaintUpdate.
Recipient Priority:
1. Staff with user account
2. Staff with email field
3. Department manager
"""
complaint = self.get_object()
# Get email message (required)
email_message = request.data.get("email_message", "").strip()
if not email_message:
return Response({"error": "email_message is required"}, status=status.HTTP_400_BAD_REQUEST)
# Get additional message (optional)
additional_message = request.data.get("additional_message", "").strip()
# Determine recipient with priority logic
recipient = None
recipient_display = None
recipient_type = None
recipient_email = None
# Priority 1: Staff member with user account
if complaint.staff and complaint.staff.user:
recipient = complaint.staff.user
recipient_display = str(complaint.staff)
recipient_type = "Staff Member (User Account)"
recipient_email = recipient.email
# Priority 2: Staff member with email field (no user account)
elif complaint.staff and complaint.staff.email:
recipient_display = str(complaint.staff)
recipient_type = "Staff Member (Email)"
recipient_email = complaint.staff.email
# Priority 3: Department head
elif complaint.department and complaint.department.manager:
recipient = complaint.department.manager
recipient_display = recipient.get_full_name()
recipient_type = "Department Head"
recipient_email = recipient.email
# Check if we found a recipient with email
if not recipient_email:
return Response(
{
"error": "No valid recipient found. Complaint must have staff with email, or a department manager with email."
},
status=status.HTTP_400_BAD_REQUEST,
)
# Construct email content
subject = f"Complaint Notification - #{complaint.id}"
# Build email body
email_body = f"""
Dear {recipient_display},
You have been assigned to review the following complaint:
COMPLAINT DETAILS:
----------------
ID: #{complaint.id}
Title: {complaint.title}
Severity: {complaint.get_severity_display()}
Priority: {complaint.get_priority_display()}
Status: {complaint.get_status_display()}
SUMMARY:
--------
{email_message}
"""
# Add patient info if available
if complaint.patient:
email_body += f"""
PATIENT INFORMATION:
------------------
Name: {complaint.patient.get_full_name()}
MRN: {complaint.patient.mrn}
"""
# Add additional message if provided
if additional_message:
email_body += f"""
ADDITIONAL MESSAGE:
------------------
{additional_message}
"""
# Add link to complaint
from django.contrib.sites.shortcuts import get_current_site
site = get_current_site(request)
complaint_url = f"https://{site.domain}/complaints/{complaint.id}/"
email_body += f"""
To view the full complaint details, please visit:
{complaint_url}
Thank you for your attention to this matter.
---
This is an automated message from PX360 Complaint Management System.
"""
# Send email using NotificationService
from apps.notifications.services import NotificationService, get_email_header_html
try:
notification_log = NotificationService.send_email(
email=recipient_email,
subject=subject,
message=email_body,
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">{subject}</h2>
<p style="margin: 0 0 12px 0;">Dear {recipient_display},</p>
<p style="margin: 0 0 12px 0;">You have been assigned to review the following complaint:</p>
<table style="width: 100%; border-collapse: collapse; margin: 0 0 12px 0;">
<tr><td style="padding: 4px 0; color: #6b7280; width: 120px;">Reference:</td><td style="padding: 4px 0;"><strong>#{complaint.id}</strong></td></tr>
<tr><td style="padding: 4px 0; color: #6b7280;">Title:</td><td style="padding: 4px 0;">{complaint.title}</td></tr>
<tr><td style="padding: 4px 0; color: #6b7280;">Severity:</td><td style="padding: 4px 0;">{complaint.get_severity_display()}</td></tr>
<tr><td style="padding: 4px 0; color: #6b7280;">Priority:</td><td style="padding: 4px 0;">{complaint.get_priority_display()}</td></tr>
</table>
<div style="text-align: center; margin: 20px 0;">
<a href="{complaint_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Complaint</a>
</div>
</div>
</div>
""",
related_object=complaint,
metadata={
"notification_type": "complaint_notification",
"recipient_type": recipient_type,
"recipient_id": str(recipient.id) if recipient else None,
"sender_id": str(request.user.id),
"has_additional_message": bool(additional_message),
},
)
except Exception as e:
return Response({"error": f"Failed to send email: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Create ComplaintUpdate entry
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="communication",
message=f"Email notification sent to {recipient_type}: {recipient_display}",
created_by=request.user,
metadata={
"recipient_type": recipient_type,
"recipient_id": str(recipient.id) if recipient else None,
"notification_log_id": str(notification_log.id) if notification_log else None,
},
)
# Log audit
AuditService.log_from_request(
event_type="notification_sent",
description=f"Email notification sent to {recipient_type}: {recipient_display}",
request=request,
content_object=complaint,
metadata={
"recipient_type": recipient_type,
"recipient_id": str(recipient.id) if recipient else None,
"recipient_email": recipient_email,
},
)
return Response(
{
"success": True,
"message": "Email notification sent successfully",
"recipient": recipient_display,
"recipient_type": recipient_type,
"recipient_email": recipient_email,
}
)
@action(detail=True, methods=["post"])
def send_to_department(self, request, pk=None):
"""
Send complaint to department for response.
Delegates to ComplaintService for shared logic.
Routes through department champion.
"""
complaint = self.get_object()
if not complaint.is_active_status:
return Response(
{"error": f"Cannot send complaint to department with status '{complaint.get_status_display()}'"},
status=status.HTTP_400_BAD_REQUEST,
)
involved_staff = complaint.involved_staff.select_related(
"staff", "staff__department", "staff__department__champion", "staff__department__manager"
).all()
if not involved_staff.exists():
return Response(
{"error": "No involved staff found for this complaint"},
status=status.HTTP_400_BAD_REQUEST,
)
department_groups = {}
for staff_inv in involved_staff:
staff = staff_inv.staff
dept = staff.department
if dept and dept.champion:
dept_key = str(dept.id)
if dept_key not in department_groups:
champion = dept.champion
champion_email = champion.email or (champion.user.email if hasattr(champion, 'user') and champion.user else None)
dept_manager = dept.manager
department_groups[dept_key] = {
"department_id": dept_key,
"department_name": dept.get_localized_name(),
"champion": champion,
"champion_id": str(champion.id),
"champion_name": champion.get_full_name(),
"champion_email": champion_email,
"dept_manager": dept_manager,
"dept_manager_id": str(dept_manager.id) if dept_manager else None,
"dept_manager_name": dept_manager.get_full_name() if dept_manager else None,
"dept_manager_email": dept_manager.email if dept_manager else None,
"staff_list": [],
}
department_groups[dept_key]["staff_list"].append({
"staff_name": staff.get_full_name(),
"role": staff_inv.get_role_display(),
})
if not department_groups:
return Response(
{"error": "No departments with assigned champions found for involved staff"},
status=status.HTTP_400_BAD_REQUEST,
)
request_message = request.data.get("request_message", "").strip()
from django.contrib.sites.shortcuts import get_current_site
site = get_current_site(request)
domain = site.domain
selected_dept_ids = list(department_groups.keys())
try:
result = ComplaintService.send_to_department(
complaint,
department_groups,
selected_dept_ids,
request_message,
request.user,
domain,
request=request,
)
except ComplaintServiceError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{
"success": result["champion_count"] > 0,
"message": "Explanation requests sent to department champions",
"results": result["results"],
"champion_count": result["champion_count"],
"manager_notified": result["manager_count"] > 0,
}
)
@action(detail=True, methods=["post"])
def resend_explanation(self, request, pk=None):
"""
Resend explanation request email to staff member only.
Regenerates the token with a new value and resends the email to the staff member.
Manager is not resent the informational email - they already received it initially.
Only allows resending if explanation has not been submitted yet.
"""
complaint = self.get_object()
# Check if complaint is in active status
if not complaint.is_active_status:
return Response(
{
"error": f"Cannot resend explanation for complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved."
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if complaint has staff assigned
if not complaint.staff:
return Response({"error": "No staff assigned to this complaint"}, status=status.HTTP_400_BAD_REQUEST)
# Check if explanation exists for this staff
from .models import ComplaintExplanation
try:
explanation = ComplaintExplanation.objects.filter(complaint=complaint, staff=complaint.staff).latest(
"created_at"
)
except ComplaintExplanation.DoesNotExist:
return Response(
{"error": "No explanation found for this complaint and staff"}, status=status.HTTP_404_NOT_FOUND
)
# Check if already submitted (can only resend if not submitted)
if explanation.is_used:
return Response(
{"error": "Explanation already submitted, cannot resend. Create a new explanation request."},
status=status.HTTP_400_BAD_REQUEST,
)
# Generate new token
import secrets
new_token = secrets.token_urlsafe(32)
explanation.token = new_token
explanation.email_sent_at = timezone.now()
explanation.save()
# Determine recipient email
if complaint.staff.user and complaint.staff.user.email:
recipient_email = complaint.staff.user.email
recipient_display = str(complaint.staff)
elif complaint.staff.email:
recipient_email = complaint.staff.email
recipient_display = str(complaint.staff)
else:
return Response({"error": "Staff member has no email address"}, status=status.HTTP_400_BAD_REQUEST)
# Send email with new link
from django.contrib.sites.shortcuts import get_current_site
from apps.notifications.services import NotificationService, get_email_header_html
site = get_current_site(request)
explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{new_token}/"
# Build email subject
subject = f"Explanation Request (Resent) - Complaint #{complaint.id}"
# Build email body
email_body = f"""
Dear {recipient_display},
We have resent the explanation request for the following complaint:
COMPLAINT DETAILS:
----------------
Reference: #{complaint.id}
Title: {complaint.title}
Severity: {complaint.get_severity_display()}
Priority: {complaint.get_priority_display()}
Status: {complaint.get_status_display()}
{complaint.description}
"""
# Add patient info if available
if complaint.patient:
email_body += f"""
PATIENT INFORMATION:
------------------
Name: {complaint.patient.get_full_name()}
MRN: {complaint.patient.mrn}
"""
email_body += f"""
SUBMIT YOUR EXPLANATION:
------------------------
Your perspective is important. Please submit your explanation about this complaint:
{explanation_link}
Note: This link can only be used once. After submission, it will expire.
If you have any questions, please contact PX team.
---
This is an automated message from PX360 Complaint Management System.
"""
# Send email
try:
notification_log = NotificationService.send_email(
email=recipient_email,
subject=subject,
message=email_body,
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Explanation Request (Resent)</h2>
<p style="margin: 0 0 12px 0;">Dear {recipient_display},</p>
<p style="margin: 0 0 12px 0;">We have resent the explanation request for complaint <strong>#{complaint.id}</strong>:</p>
<table style="width: 100%; border-collapse: collapse; margin: 0 0 12px 0;">
<tr><td style="padding: 4px 0; color: #6b7280; width: 120px;">Title:</td><td style="padding: 4px 0;">{complaint.title}</td></tr>
<tr><td style="padding: 4px 0; color: #6b7280;">Severity:</td><td style="padding: 4px 0;">{complaint.get_severity_display()}</td></tr>
<tr><td style="padding: 4px 0; color: #6b7280;">Priority:</td><td style="padding: 4px 0;">{complaint.get_priority_display()}</td></tr>
</table>
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">This link can only be used once. After submission, it will expire.</p>
<div style="text-align: center; margin: 20px 0;">
<a href="{explanation_link}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Submit Explanation</a>
</div>
</div>
</div>
""",
related_object=complaint,
metadata={
"notification_type": "explanation_request_resent",
"recipient_type": "staff",
"staff_id": str(complaint.staff.id),
"explanation_id": str(explanation.id),
"requested_by_id": str(request.user.id),
"resent": True,
},
)
except Exception as e:
return Response({"error": f"Failed to send email: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Create ComplaintUpdate entry
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="communication",
message=f"Explanation request resent to {recipient_display}",
created_by=request.user,
metadata={
"explanation_id": str(explanation.id),
"staff_id": str(complaint.staff.id),
"notification_log_id": str(notification_log.id) if notification_log else None,
"resent": True,
},
)
# Log audit
AuditService.log_from_request(
event_type="explanation_resent",
description=f"Explanation request resent to {recipient_display}",
request=request,
content_object=complaint,
metadata={"explanation_id": str(explanation.id), "staff_id": str(complaint.staff.id)},
)
return Response(
{
"success": True,
"message": "Explanation request resent successfully to staff member",
"explanation_id": str(explanation.id),
"recipient": recipient_display,
"new_token": new_token,
"explanation_link": explanation_link,
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["post"])
def send_explanation_reminder(self, request, pk=None):
"""
Manually send first or second reminder for explanation request.
Allows admin to trigger a reminder email when the automated task didn't run.
"""
complaint = self.get_object()
if not complaint.is_active_status:
return Response(
{"error": f"Cannot send reminder for complaint with status '{complaint.get_status_display()}'."},
status=status.HTTP_400_BAD_REQUEST,
)
explanation_id = request.data.get("explanation_id")
reminder_type = request.data.get("reminder_type", "first")
if not explanation_id:
return Response({"error": "explanation_id is required"}, status=status.HTTP_400_BAD_REQUEST)
try:
explanation = ComplaintExplanation.objects.get(pk=explanation_id, complaint=complaint)
except ComplaintExplanation.DoesNotExist:
return Response({"error": "Explanation not found"}, status=status.HTTP_404_NOT_FOUND)
if explanation.is_used:
return Response({"error": "Explanation already submitted"}, status=status.HTTP_400_BAD_REQUEST)
if reminder_type not in ("first", "second"):
return Response({"error": "reminder_type must be 'first' or 'second'"}, status=status.HTTP_400_BAD_REQUEST)
if reminder_type == "first":
if explanation.reminder_sent_at:
return Response({"error": "First reminder already sent"}, status=status.HTTP_400_BAD_REQUEST)
else:
if not explanation.reminder_sent_at:
return Response(
{"error": "First reminder must be sent before second reminder"},
status=status.HTTP_400_BAD_REQUEST,
)
if explanation.second_reminder_sent_at:
return Response({"error": "Second reminder already sent"}, status=status.HTTP_400_BAD_REQUEST)
if not explanation.staff.email:
return Response({"error": "Staff member has no email address"}, status=status.HTTP_400_BAD_REQUEST)
now = timezone.now()
hours_remaining = 0
if explanation.sla_due_at:
hours_remaining = max(0, int((explanation.sla_due_at - now).total_seconds() / 3600))
context = {
"explanation": explanation,
"complaint": complaint,
"staff": explanation.staff,
"hours_remaining": hours_remaining,
"due_date": explanation.sla_due_at,
"site_url": request.build_absolute_uri("/").rstrip("/"),
}
if reminder_type == "first":
subject = f"Reminder: Explanation Request - Complaint #{str(complaint.id)[:8]}"
try:
from django.template.loader import render_to_string
from django.core.mail import send_mail
from django.conf import settings
message_en = render_to_string("complaints/emails/explanation_reminder_en.txt", context)
message_ar = render_to_string("complaints/emails/explanation_reminder_ar.txt", context)
html_message = render_to_string("emails/explanation_reminder.html", context)
send_mail(
subject=subject,
message=f"{message_en}\n\n{message_ar}",
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[explanation.staff.email],
fail_silently=False,
html_message=html_message,
)
explanation.reminder_sent_at = now
explanation.save(update_fields=["reminder_sent_at"])
except Exception as e:
return Response(
{"error": f"Failed to send reminder: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
else:
subject = f"URGENT - Final Reminder: Explanation Request - Complaint #{str(complaint.id)[:8]}"
try:
from django.template.loader import render_to_string
from django.core.mail import send_mail
from django.conf import settings
message_en = render_to_string("complaints/emails/explanation_second_reminder_en.txt", context)
message_ar = render_to_string("complaints/emails/explanation_second_reminder_ar.txt", context)
html_message = render_to_string("emails/explanation_second_reminder.html", context)
send_mail(
subject=subject,
message=f"{message_en}\n\n{message_ar}",
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[explanation.staff.email],
fail_silently=False,
html_message=html_message,
)
explanation.second_reminder_sent_at = now
explanation.save(update_fields=["second_reminder_sent_at"])
except Exception as e:
return Response(
{"error": f"Failed to send reminder: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
label = "first" if reminder_type == "first" else "second"
recipient_display = str(explanation.staff)
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="communication",
message=f"{label.capitalize()} explanation reminder sent to {recipient_display}",
created_by=request.user,
metadata={
"explanation_id": str(explanation.id),
"staff_id": str(explanation.staff.id),
"reminder_type": reminder_type,
},
)
AuditService.log_from_request(
event_type=f"explanation_{label}_reminder_sent",
description=f"{label.capitalize()} explanation reminder sent to {recipient_display}",
request=request,
content_object=complaint,
metadata={"explanation_id": str(explanation.id), "staff_id": str(explanation.staff.id)},
)
return Response(
{
"success": True,
"message": f"{label.capitalize()} reminder sent to {recipient_display}",
"explanation_id": str(explanation.id),
"reminder_type": reminder_type,
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["post"])
def review_explanation(self, request, pk=None):
"""
Review and mark an explanation as acceptable or not acceptable.
Allows PX Admins to review submitted explanations and mark them.
"""
complaint = self.get_object()
# Check permission
if not (request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()):
return Response(
{"error": "Only PX team members can review explanations"}, status=status.HTTP_403_FORBIDDEN
)
explanation_id = request.data.get("explanation_id")
acceptance_status = request.data.get("acceptance_status")
acceptance_notes = request.data.get("acceptance_notes", "")
if not explanation_id:
return Response({"error": "explanation_id is required"}, status=status.HTTP_400_BAD_REQUEST)
if not acceptance_status:
return Response(
{"error": "acceptance_status is required (acceptable or not_acceptable)"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate acceptance status
from .models import ComplaintExplanation
valid_statuses = [
ComplaintExplanation.AcceptanceStatus.ACCEPTABLE,
ComplaintExplanation.AcceptanceStatus.NOT_ACCEPTABLE,
]
if acceptance_status not in valid_statuses:
return Response(
{"error": f"Invalid acceptance_status. Must be one of: {valid_statuses}"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the explanation
try:
explanation = ComplaintExplanation.objects.get(id=explanation_id, complaint=complaint)
except ComplaintExplanation.DoesNotExist:
return Response({"error": "Explanation not found"}, status=status.HTTP_404_NOT_FOUND)
# Check if explanation has been submitted
if not explanation.is_used:
return Response(
{"error": "Cannot review explanation that has not been submitted yet"},
status=status.HTTP_400_BAD_REQUEST,
)
# Update explanation
explanation.acceptance_status = acceptance_status
explanation.accepted_by = request.user
explanation.accepted_at = timezone.now()
explanation.acceptance_notes = acceptance_notes
explanation.save()
# Create complaint update
status_display = (
"Acceptable" if acceptance_status == ComplaintExplanation.AcceptanceStatus.ACCEPTABLE else "Not Acceptable"
)
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Explanation from {explanation.staff} marked as {status_display}",
created_by=request.user,
metadata={
"explanation_id": str(explanation.id),
"staff_id": str(explanation.staff.id) if explanation.staff else None,
"acceptance_status": acceptance_status,
"acceptance_notes": acceptance_notes,
},
)
# Log audit
AuditService.log_from_request(
event_type="explanation_reviewed",
description=f"Explanation marked as {status_display}",
request=request,
content_object=explanation,
metadata={
"explanation_id": str(explanation.id),
"acceptance_status": acceptance_status,
"acceptance_notes": acceptance_notes,
},
)
return Response(
{
"success": True,
"message": f"Explanation marked as {status_display}",
"explanation_id": str(explanation.id),
"acceptance_status": acceptance_status,
"accepted_at": explanation.accepted_at,
"accepted_by": request.user.get_full_name(),
}
)
@action(detail=True, methods=["post"])
def escalate_explanation(self, request, pk=None):
"""
Escalate an explanation to the staff's manager.
Marks the explanation as not acceptable and sends an explanation request
to the staff's manager (report_to).
"""
complaint = self.get_object()
# Check permission
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
return Response(
{"error": "Only PX Admins or Hospital Admins can escalate explanations"},
status=status.HTTP_403_FORBIDDEN,
)
explanation_id = request.data.get("explanation_id")
acceptance_notes = request.data.get("acceptance_notes", "")
if not explanation_id:
return Response({"error": "explanation_id is required"}, status=status.HTTP_400_BAD_REQUEST)
# Get the explanation
try:
explanation = ComplaintExplanation.objects.select_related("staff", "staff__report_to").get(
id=explanation_id, complaint=complaint
)
except ComplaintExplanation.DoesNotExist:
return Response({"error": "Explanation not found"}, status=status.HTTP_404_NOT_FOUND)
# Check if explanation has been submitted
if not explanation.is_used:
return Response(
{"error": "Cannot escalate explanation that has not been submitted yet"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if already escalated
if explanation.escalated_to_manager:
return Response({"error": "Explanation has already been escalated"}, status=status.HTTP_400_BAD_REQUEST)
# Use fallback chain to find escalation target
from apps.complaints.services.complaint_service import ComplaintService
from apps.organizations.models import Staff
target_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=explanation.staff)
if not target_user:
return Response(
{"error": f"No escalation target found (tried: {fallback_path})"},
status=status.HTTP_400_BAD_REQUEST,
)
manager = Staff.objects.filter(user=target_user).first()
if not manager:
return Response(
{"error": f"Escalation target {target_user.get_full_name()} has no Staff record"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if manager already has an explanation request for this complaint
existing_manager_explanation = ComplaintExplanation.objects.filter(complaint=complaint, staff=manager).first()
if existing_manager_explanation:
return Response(
{"error": f"Manager {manager.get_full_name()} already has an explanation request for this complaint"},
status=status.HTTP_400_BAD_REQUEST,
)
# Generate token for manager explanation
import secrets
manager_token = secrets.token_urlsafe(32)
request_message = f"Escalated from staff explanation. Staff: {explanation.staff.get_full_name() if explanation.staff else 'Unknown'}. Notes: {acceptance_notes}"
if fallback_path != "staff.report_to":
request_message += f" [Escalated via fallback: {fallback_path}]"
# Create manager explanation record
manager_explanation = ComplaintExplanation.objects.create(
complaint=complaint,
staff=manager,
token=manager_token,
is_used=False,
requested_by=request.user,
request_message=request_message,
submitted_via="email_link",
email_sent_at=timezone.now(),
metadata={
"escalation_fallback_path": fallback_path,
},
)
# Update original explanation
explanation.acceptance_status = ComplaintExplanation.AcceptanceStatus.NOT_ACCEPTABLE
explanation.accepted_by = request.user
explanation.accepted_at = timezone.now()
explanation.acceptance_notes = acceptance_notes
explanation.escalated_to_manager = manager_explanation
explanation.escalated_at = timezone.now()
explanation.save()
# Send email to manager
from django.contrib.sites.shortcuts import get_current_site
from apps.notifications.services import NotificationService, get_email_header_html
site = get_current_site(request)
explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{manager_token}/"
manager_email = manager.email or (manager.user.email if manager.user else None)
if manager_email:
subject = f"Escalated Explanation Request - Complaint #{complaint.reference_number}"
email_body = f"""Dear {manager.get_full_name()},
An explanation submitted by a staff member who reports to you has been marked as not acceptable and escalated to you for further review.
STAFF MEMBER:
------------
Name: {explanation.staff.get_full_name() if explanation.staff else "Unknown"}
Employee ID: {explanation.staff.employee_id if explanation.staff else "N/A"}
Department: {explanation.staff.department.name if explanation.staff and explanation.staff.department else "N/A"}
COMPLAINT DETAILS:
----------------
Reference: {complaint.reference_number}
Title: {complaint.title}
Severity: {complaint.get_severity_display()}
Priority: {complaint.get_priority_display()}
ORIGINAL EXPLANATION (Not Acceptable):
--------------------------------------
{explanation.explanation}
ESCALATION NOTES:
-----------------
{acceptance_notes if acceptance_notes else "No additional notes provided."}
PLEASE SUBMIT YOUR EXPLANATION:
------------------------------
As the manager, please submit your perspective on this matter:
{explanation_link}
Note: This link can only be used once. After submission, it will expire.
---
This is an automated message from PX360 Complaint Management System.
"""
try:
NotificationService.send_email(
email=manager_email,
subject=subject,
message=email_body,
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Escalated Explanation Request</h2>
<p style="margin: 0 0 12px 0;">The original explanation was not acceptable and this request has been escalated to you for review.</p>
<table style="width: 100%; border-collapse: collapse; margin: 0 0 12px 0;">
<tr><td style="padding: 4px 0; color: #6b7280; width: 140px;">Reference:</td><td style="padding: 4px 0;"><strong>{complaint.reference_number}</strong></td></tr>
<tr><td style="padding: 4px 0; color: #6b7280;">Title:</td><td style="padding: 4px 0;">{complaint.title}</td></tr>
<tr><td style="padding: 4px 0; color: #6b7280;">Severity:</td><td style="padding: 4px 0;">{complaint.get_severity_display()}</td></tr>
<tr><td style="padding: 4px 0; color: #6b7280;">Priority:</td><td style="padding: 4px 0;">{complaint.get_priority_display()}</td></tr>
</table>
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">This link can only be used once. After submission, it will expire.</p>
<div style="text-align: center; margin: 20px 0;">
<a href="{explanation_link}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Submit Your Explanation</a>
</div>
</div>
</div>
""",
related_object=complaint,
metadata={
"notification_type": "escalated_explanation_request",
"manager_id": str(manager.id),
"staff_id": str(explanation.staff.id) if explanation.staff else None,
"complaint_id": str(complaint.id),
"original_explanation_id": str(explanation.id),
},
)
email_sent = True
except Exception as e:
logger.error(f"Failed to send escalation email to manager: {e}")
email_sent = False
else:
email_sent = False
# Create complaint update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Explanation from {explanation.staff} marked as Not Acceptable and escalated to manager {manager.get_full_name()}",
created_by=request.user,
metadata={
"explanation_id": str(explanation.id),
"staff_id": str(explanation.staff.id) if explanation.staff else None,
"manager_id": str(manager.id),
"manager_explanation_id": str(manager_explanation.id),
"acceptance_status": "not_acceptable",
"acceptance_notes": acceptance_notes,
"email_sent": email_sent,
},
)
# Log audit
AuditService.log_from_request(
event_type="explanation_escalated",
description=f"Explanation escalated to manager {manager.get_full_name()}",
request=request,
content_object=explanation,
metadata={
"explanation_id": str(explanation.id),
"manager_id": str(manager.id),
"manager_explanation_id": str(manager_explanation.id),
"email_sent": email_sent,
},
)
return Response(
{
"success": True,
"message": f"Explanation escalated to manager {manager.get_full_name()}",
"explanation_id": str(explanation.id),
"manager_explanation_id": str(manager_explanation.id),
"manager_name": manager.get_full_name(),
"manager_email": manager_email,
"email_sent": email_sent,
}
)
@action(detail=True, methods=["post"])
def generate_ai_resolution(self, request, pk=None):
"""
Generate AI-powered resolution note based on complaint details and explanations.
Analyzes the complaint description, staff explanations, and manager explanations
to generate a comprehensive resolution note for admin review.
"""
complaint = self.get_object()
# Check permission - same logic as can_manage_complaint
user = request.user
can_generate = (
user.is_px_admin()
or (user.is_hospital_admin() and user.hospital == complaint.hospital)
or (user.is_department_manager() and user.department == complaint.department)
or complaint.assigned_to == user
)
if not can_generate:
return Response(
{"error": "You do not have permission to generate AI resolution for this complaint"},
status=status.HTTP_403_FORBIDDEN,
)
# Get all used explanations
explanations = complaint.explanations.filter(is_used=True).select_related("staff")
if not explanations.exists():
return Response(
{"success": False, "error": "No explanations available to analyze. Please request explanations first."},
status=status.HTTP_400_BAD_REQUEST,
)
# Build context for AI
context = {
"complaint": {
"title": complaint.title,
"description": complaint.description,
"severity": complaint.get_severity_display(),
"priority": complaint.get_priority_display(),
"patient_name": complaint.patient.get_full_name() if complaint.patient else "Unknown",
"department": complaint.department.name if complaint.department else "Unknown",
},
"explanations": [],
}
for exp in explanations:
exp_data = {
"staff_name": exp.staff.get_full_name() if exp.staff else "Unknown",
"employee_id": exp.staff.employee_id if exp.staff else "N/A",
"department": exp.staff.department.name if exp.staff and exp.staff.department else "N/A",
"explanation": exp.explanation,
"acceptance_status": exp.get_acceptance_status_display(),
"submitted_at": exp.responded_at.strftime("%Y-%m-%d %H:%M") if exp.responded_at else "Unknown",
}
context["explanations"].append(exp_data)
# Call AI service to generate resolution
try:
from apps.core.ai_service import AIService
# Build prompt
explanations_text = ""
for i, exp in enumerate(context["explanations"], 1):
explanations_text += f"""
Explanation {i}:
- Staff: {exp["staff_name"]} (ID: {exp["employee_id"]}, Dept: {exp["department"]})
- Status: {exp["acceptance_status"]}
- Submitted: {exp["submitted_at"]}
- Content: {exp["explanation"]}
"""
prompt = f"""As a healthcare complaint resolution expert, analyze the following complaint and staff explanations to generate a comprehensive resolution note in BOTH English and Arabic.
COMPLAINT DETAILS:
- Title: {context["complaint"]["title"]}
- Description: {context["complaint"]["description"]}
- Severity: {context["complaint"]["severity"]}
- Priority: {context["complaint"]["priority"]}
- Patient: {context["complaint"]["patient_name"]}
- Department: {context["complaint"]["department"]}
STAFF EXPLANATIONS:
{explanations_text}
Based on the above information, generate a professional resolution note that:
1. Summarizes the main issue and root cause
2. References the key points from staff explanations
3. States the outcome/decision
4. Includes any corrective actions taken or planned
5. Addresses patient concerns
6. Mentions any follow-up actions
The resolution should be written in a professional, empathetic tone suitable for healthcare settings.
IMPORTANT: Provide the resolution in BOTH languages as JSON:
{{
"resolution_en": "The resolution text in English (3-5 paragraphs)",
"resolution_ar": "نص القرار بالعربية (3-5 فقرات)"
}}
Ensure both versions convey the same meaning and are professionally written."""
system_prompt = """You are an expert healthcare complaint resolution specialist fluent in both English and Arabic.
Your task is to analyze complaints and staff explanations to generate comprehensive, professional resolution notes in both languages.
Be objective, empathetic, and thorough. Focus on facts while acknowledging the patient's concerns.
Write in a professional tone appropriate for medical records in both languages.
Always provide valid JSON output with both resolution_en and resolution_ar fields."""
ai_response = AIService.chat_completion(
prompt=prompt,
system_prompt=system_prompt,
temperature=0.4,
max_tokens=1500,
response_format="json_object",
)
# Parse the JSON response
import json
import re
try:
resolution_data = json.loads(ai_response)
except json.JSONDecodeError:
# AI returned malformed JSON — try to extract fields via regex
en_match = re.search(r'"resolution_en"\s*:\s*"((?:[^"\\]|\\.)*)"', ai_response, re.DOTALL)
ar_match = re.search(r'"resolution_ar"\s*:\s*"((?:[^"\\]|\\.)*)"', ai_response, re.DOTALL)
resolution_data = {
"resolution_en": en_match.group(1).replace("\\n", "\n").replace('\\"', '"') if en_match else ai_response,
"resolution_ar": ar_match.group(1).replace("\\n", "\n").replace('\\"', '"') if ar_match else "",
}
resolution_en = resolution_data.get("resolution_en", "").strip()
resolution_ar = resolution_data.get("resolution_ar", "").strip()
# Log the AI generation
AuditService.log_from_request(
event_type="ai_resolution_generated",
description=f"AI resolution generated for complaint {complaint.reference_number}",
request=request,
content_object=complaint,
metadata={
"complaint_id": str(complaint.id),
"explanation_count": explanations.count(),
"generated_resolution_en_length": len(resolution_en),
"generated_resolution_ar_length": len(resolution_ar),
},
)
return Response(
{
"success": True,
"resolution_en": resolution_en,
"resolution_ar": resolution_ar,
"explanation_count": explanations.count(),
}
)
except Exception as e:
logger.error(f"AI resolution generation failed: {e}")
return Response(
{"success": False, "error": f"Failed to generate resolution: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@action(detail=True, methods=["get"])
def generate_resolution_suggestion(self, request, pk=None):
"""
Generate AI resolution suggestion based on complaint and acceptable explanation.
Uses the staff explanation if acceptable, otherwise uses manager explanation.
Returns a suggested resolution text that can be edited or used directly.
"""
complaint = self.get_object()
# Check permission
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
return Response(
{"error": "Only PX Admins or Hospital Admins can generate resolution suggestions"},
status=status.HTTP_403_FORBIDDEN,
)
# Find acceptable explanation
acceptable_explanation = None
explanation_source = None
# First, try to find an acceptable staff explanation
staff_explanation = complaint.explanations.filter(
staff=complaint.staff, is_used=True, acceptance_status=ComplaintExplanation.AcceptanceStatus.ACCEPTABLE
).first()
if staff_explanation:
acceptable_explanation = staff_explanation
explanation_source = "staff"
else:
# Try to find an acceptable manager explanation (escalated)
manager_explanation = complaint.explanations.filter(
is_used=True,
acceptance_status=ComplaintExplanation.AcceptanceStatus.ACCEPTABLE,
metadata__is_escalation=True,
).first()
if manager_explanation:
acceptable_explanation = manager_explanation
explanation_source = "manager"
if not acceptable_explanation:
return Response(
{
"error": "No acceptable explanation found. Please review and mark an explanation as acceptable first.",
"suggestion": None,
},
status=status.HTTP_400_BAD_REQUEST,
)
# Generate resolution using AI
try:
resolution_text = self._generate_ai_resolution(
complaint=complaint, explanation=acceptable_explanation, source=explanation_source
)
return Response(
{
"success": True,
"suggestion": resolution_text,
"source": explanation_source,
"source_staff": acceptable_explanation.staff.get_full_name()
if acceptable_explanation.staff
else None,
"explanation_id": str(acceptable_explanation.id),
}
)
except Exception as e:
logger.error(f"Failed to generate resolution: {e}")
return Response(
{"error": "Failed to generate resolution suggestion", "detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def _generate_ai_resolution(self, complaint, explanation, source):
explanation_text = explanation.explanation
explanation_by = explanation.staff.get_full_name() if explanation.staff else "Unknown"
try:
from apps.core.ai_service import AIService
prompt = f"""Based on the following complaint and investigation, generate a professional resolution summary that will be sent to the patient. The response should be empathetic, clear, and describe the actions taken.
Complaint: {complaint.description[:1000]}
Severity: {complaint.get_severity_display()}
Category: {complaint.category.get_localized_name() if complaint.category else 'N/A'}
Explanation by {explanation_by} ({source}): {explanation_text[:1000]}
Generate a JSON response with:
- "resolution_en": Professional resolution summary in English (2-3 paragraphs)
- "resolution_ar": Same resolution in Arabic"""
result = AIService.chat_completion(
prompt=prompt,
response_format="json_object",
)
import json
parsed = json.loads(result)
return parsed.get("resolution_en", result)
except Exception as e:
logger.warning(f"AI resolution generation failed, using template: {e}")
return f"""Based on the complaint filed regarding: {complaint.title}
After reviewing the complaint and the explanation provided by {explanation_by} ({source}), the following has been determined:
{explanation_text}
The matter has been addressed through appropriate channels. Appropriate measures have been implemented to address the concern and steps have been taken to prevent recurrence. The complaint is considered resolved."""
@action(detail=True, methods=["get"])
def ai_helper_suggestions(self, request, pk=None):
complaint = self.get_object()
if not (request.user.is_px_admin or request.user.is_hospital_admin()):
return Response({"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN)
try:
from apps.core.ai_service import AIService
from django.utils import timezone
import json
explanations_text = ""
for exp in complaint.explanations.filter(is_used=True):
name = exp.staff.get_full_name() if exp.staff else "Staff"
explanations_text += f"\n- {name}: {exp.explanation[:500]}"
prompt = f"""You are a patient experience resolution advisor. Based on this complaint, provide actionable suggestions for responding to the patient.
Complaint: {complaint.description[:1500]}
Severity: {complaint.get_severity_display()}
Category: {complaint.category.get_localized_name() if complaint.category else 'N/A'}
Priority: {complaint.get_priority_display()}
Status: {complaint.get_status_display()}
Staff explanations received: {explanations_text or 'None yet'}
IMPORTANT: ALL text fields must be provided in BOTH English and Arabic.
Generate a JSON response with this exact structure:
{{
"suggestions": [
{{"title_en": "Short title", "title_ar": "عنوان قصير", "description_en": "Description", "description_ar": "الوصف"}}
],
"recommended_actions_en": ["Action 1", "Action 2"],
"recommended_actions_ar": ["الإجراء 1", "الإجراء 2"],
"communication_tip_en": "Tip text",
"communication_tip_ar": "نصيحة"
}}
Provide 3-5 suggestions. Keep them concise and focused on how to respond to the patient."""
result = AIService.chat_completion(
prompt=prompt,
response_format="json_object",
)
parsed = json.loads(result)
# Persist to complaint
complaint.ai_response_suggestions = parsed
complaint.ai_response_suggestions_at = timezone.now()
complaint.save(update_fields=["ai_response_suggestions", "ai_response_suggestions_at", "updated_at"])
return Response({"success": True})
except Exception as e:
logger.error(f"AI helper suggestion failed: {e}")
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _get_pdf_context_data(self, complaint):
"""Compute all PDF template context fields from DB data."""
from apps.complaints.models import ComplaintInvolvedDepartment, ComplaintInvolvedStaff
def fmt_date_ar(dt):
if not dt:
return ""
return dt.strftime("%Y/%m/%d %p %I:%M").replace("AM", "ص").replace("PM", "م")
hospital_name = (
complaint.hospital.get_display_name_ar()
if complaint.hospital and complaint.hospital.get_display_name_ar()
else complaint.hospital.get_display_name() if complaint.hospital else ""
)
dept_name = (
complaint.department.name_ar
if complaint.department and complaint.department.name_ar
else complaint.department.name_en or complaint.department.name if complaint.department else ""
)
complainant_name = (
complaint.contact_name
or complaint.patient_name
or (complaint.patient.get_full_name() if complaint.patient else "")
)
source_name = complaint.source.name_ar if complaint.source else ""
accused = ComplaintInvolvedStaff.objects.filter(
complaint=complaint, role="accused"
).select_related("staff", "staff__department").first()
accused_staff_name = ""
accused_staff_title = ""
if accused and accused.staff:
s = accused.staff
accused_staff_name = s.name_ar or s.name or f"{s.first_name} {s.last_name}"
accused_staff_title = s.job_title or ""
sent_to_dept_at = complaint.sent_to_department_at or complaint.forwarded_to_dept_at
first_response_dept = ComplaintInvolvedDepartment.objects.filter(
complaint=complaint, response_submitted=True
).order_by("response_submitted_at").first()
response_submitted_at = first_response_dept.response_submitted_at if first_response_dept else None
return {
"hospital_name": hospital_name,
"department_name": dept_name,
"complainant_name": complainant_name,
"source_name": source_name,
"accused_staff_name": accused_staff_name,
"accused_staff_title": accused_staff_title,
"submission_date": fmt_date_ar(complaint.created_at),
"incident_date": complaint.incident_date.strftime("%Y/%m/%d") if complaint.incident_date else "",
"sent_to_dept_date": fmt_date_ar(sent_to_dept_at),
"response_date": fmt_date_ar(response_submitted_at),
}
@action(detail=True, methods=["get", "post"])
def summary_preview(self, request, pk=None):
"""Get existing summary (GET) or generate AI preview (POST)."""
complaint = self.get_object()
user = request.user
can_generate = (
user.is_px_admin()
or (user.is_hospital_admin() and user.hospital == complaint.hospital)
or (user.is_department_manager() and user.department == complaint.department)
or (user.is_px_employee and complaint.hospital == user.hospital)
)
if not can_generate:
return Response(
{"error": "You do not have permission to generate a PDF summary"},
status=status.HTTP_403_FORBIDDEN,
)
from .models import ComplaintPdfSummary
if request.method == "GET":
ctx = self._get_pdf_context_data(complaint)
summary = ComplaintPdfSummary.objects.filter(complaint=complaint).first()
if summary:
data = {
"exists": True,
"content_summary": summary.content_summary,
"dept_response_summary": summary.dept_response_summary,
**ctx,
}
if summary.file:
data["has_file"] = True
data["file_url"] = summary.file.url
else:
data["has_file"] = False
return Response(data)
return Response({"exists": False, **ctx})
try:
import json
from apps.complaints.models import ComplaintInvolvedDepartment
dept_response_parts = []
involved_depts = ComplaintInvolvedDepartment.objects.filter(
complaint=complaint
).select_related("department")
for dept in involved_depts:
resp = dept.response_notes_ar or dept.response_notes_en or dept.response_notes or ""
resp = resp[:600]
d_name = dept.department.name_ar or dept.department.name_en or dept.department.name
if resp:
dept_response_parts.append(f"- {d_name}: {resp}")
if not dept_response_parts and complaint.action_taken_by_dept:
dept_response_parts.append(complaint.action_taken_by_dept[:600])
dept_response_text = "\n".join(dept_response_parts) or "No department response recorded."
content_summary = complaint.description or ""
dept_response_summary = dept_response_text
ComplaintPdfSummary.objects.update_or_create(
complaint=complaint,
defaults={
"lang": "ar",
"content_summary": content_summary,
"dept_response_summary": dept_response_summary,
},
)
ctx = self._get_pdf_context_data(complaint)
return Response({
"content_summary": content_summary,
"dept_response_summary": dept_response_summary,
**ctx,
})
except Exception as e:
logger.error(f"Error generating summary preview for complaint {pk}: {e}")
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=["post"])
def generate_summary_pdf(self, request, pk=None):
"""Generate a PDF report for the complaint in the requested language (en/ar)."""
complaint = self.get_object()
user = request.user
can_generate = (
user.is_px_admin()
or (user.is_hospital_admin() and user.hospital == complaint.hospital)
or (user.is_department_manager() and user.department == complaint.department)
or (user.is_px_employee and complaint.hospital == user.hospital)
)
if not can_generate:
return Response(
{"error": "You do not have permission to generate a PDF summary"},
status=status.HTTP_403_FORBIDDEN,
)
try:
import base64
import json
from django.conf import settings
from django.template.loader import render_to_string
lang = request.data.get("lang", "ar")
from apps.complaints.models import ComplaintInvolvedDepartment
dept_response_parts = []
involved_depts = ComplaintInvolvedDepartment.objects.filter(
complaint=complaint
).select_related("department")
for dept in involved_depts:
resp = dept.response_notes_ar or dept.response_notes_en or dept.response_notes or ""
resp = resp[:600]
d_name = dept.department.name_ar or dept.department.name_en or dept.department.name
if resp:
dept_response_parts.append(f"- {d_name}: {resp}")
if not dept_response_parts and complaint.action_taken_by_dept:
dept_response_parts.append(complaint.action_taken_by_dept[:600])
dept_response_text = "\n".join(dept_response_parts) or "No department response recorded."
resolution_text = complaint.resolution or complaint.recommendation_action_plan or ""
# Use pre-written text from request, then saved summary, then AI fallback
content_summary = request.data.get("content_summary")
dept_response_summary = request.data.get("dept_response_summary")
if not content_summary or not dept_response_summary:
from .models import ComplaintPdfSummary
saved = ComplaintPdfSummary.objects.filter(complaint=complaint).first()
if saved and saved.content_summary and saved.dept_response_summary:
content_summary = saved.content_summary
dept_response_summary = saved.dept_response_summary
else:
import json
prompt = f"""You are a healthcare complaint report writer. Generate your response entirely in Modern Standard Arabic (Fusha).
COMPLAINT:
- Title: {complaint.title}
- Description: {complaint.description[:2000]}
DEPARTMENT RESPONSE / ACTIONS TAKEN:
{dept_response_text}
RESOLUTION:
{resolution_text[:600] or 'No resolution recorded.'}
Generate JSON with exactly these fields:
- "content_summary": 2-3 paragraph professional summary of the complaint content.
- "department_response_summary": 1-2 paragraph summary of the department response and actions taken."""
from apps.core.ai_service import AIService
result = AIService.chat_completion(
prompt=prompt,
response_format="json_object",
temperature=0.3,
max_tokens=1500,
)
parsed = json.loads(result)
content_summary = parsed.get("content_summary", complaint.description[:500] if complaint.description else "")
dept_response_summary = parsed.get(
"department_response_summary",
complaint.action_taken_by_dept[:500] if complaint.action_taken_by_dept else "",
)
ctx = self._get_pdf_context_data(complaint)
for field in ["complainant_name", "source_name", "department_name",
"accused_staff_name", "accused_staff_title",
"submission_date", "incident_date",
"sent_to_dept_date", "response_date"]:
val = request.data.get(field)
if val:
ctx[field] = val
import io
from PIL import Image as PILImage
def _img_to_data_uri(path):
try:
img = PILImage.open(path)
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return None
_logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png")
_logo_img.thumbnail((600, 600), PILImage.LANCZOS)
_logo_buf = io.BytesIO()
_logo_img.save(_logo_buf, format="PNG", optimize=True)
logo_path = "data:image/png;base64," + base64.b64encode(_logo_buf.getvalue()).decode()
# Per-hospital letterhead + stamp (with static fallback)
letterhead_path = None
if complaint.hospital and complaint.hospital.letterhead:
letterhead_path = _img_to_data_uri(complaint.hospital.letterhead.path)
if not letterhead_path:
letterhead_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "artboard" / "Artboard 1@3x.png")
stamp_path = None
if complaint.hospital and complaint.hospital.stamp:
stamp_path = _img_to_data_uri(complaint.hospital.stamp.path)
if not stamp_path:
stamp_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "stamps" / "stamp.png")
# QR code encoding the public tracking URL for authenticity verification
qr_code_path = None
try:
import qrcode
from django.urls import reverse
pdf_url = request.build_absolute_uri(
reverse("complaints:public_complaint_pdf", kwargs={"reference_number": complaint.reference_number})
)
qr = qrcode.QRCode(version=1, box_size=4, border=1)
qr.add_data(pdf_url)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
qr_buf = io.BytesIO()
qr_img.save(qr_buf, format="PNG")
qr_code_path = "data:image/png;base64," + base64.b64encode(qr_buf.getvalue()).decode()
except Exception:
pass
html_string = render_to_string(
"complaints/complaint_summary_pdf.html",
{
"complaint": complaint,
"lang": "ar",
"content_summary": content_summary,
"dept_response_summary": dept_response_summary,
"logo_path": logo_path,
"letterhead_path": letterhead_path,
"stamp_path": stamp_path,
"qr_code_path": qr_code_path,
"hospital_name_en": complaint.hospital.get_display_name() if complaint.hospital else "",
"hospital_address": complaint.hospital.address if complaint.hospital else "",
"hospital_phone": complaint.hospital.phone if complaint.hospital else "",
"hospital_email": complaint.hospital.email if complaint.hospital else "",
**ctx,
},
)
from weasyprint import HTML
pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
# Save PDF to persistent storage
from django.core.files.base import ContentFile
from .models import ComplaintPdfSummary
summary, _ = ComplaintPdfSummary.objects.update_or_create(
complaint=complaint,
defaults={
"lang": "ar",
"content_summary": content_summary,
"dept_response_summary": dept_response_summary,
},
)
summary.file.save(f"{complaint.pk}_ar.pdf", ContentFile(pdf_file), save=False)
summary.file_size = len(pdf_file)
summary.save()
from django.http import HttpResponse
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"complaint_summary_{complaint.reference_number}_ar_{timestamp}.pdf"
response = HttpResponse(pdf_file, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
AuditService.log_from_request(
event_type="pdf_summary_generated",
description=f"PDF report ({lang}) generated for complaint: {complaint.reference_number}",
request=request,
content_object=complaint,
metadata={"complaint_id": str(pk), "lang": lang},
)
return response
except ImportError:
return Response({"error": "WeasyPrint is not installed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error generating PDF summary for complaint {pk}: {e}")
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=["post"])
def save_resolution(self, request, pk=None):
"""
Save final resolution for the complaint.
Allows user to save an edited or directly generated resolution.
Optionally updates complaint status to RESOLVED.
"""
complaint = self.get_object()
# Check permission
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
return Response(
{"error": "Only PX Admins or Hospital Admins can save resolutions"}, status=status.HTTP_403_FORBIDDEN
)
resolution_text = request.data.get("resolution")
mark_resolved = request.data.get("mark_resolved", False)
if not resolution_text:
return Response({"error": "Resolution text is required"}, status=status.HTTP_400_BAD_REQUEST)
# Save resolution
complaint.resolution = resolution_text
complaint.resolution_category = ComplaintResolutionCategory.FULL_ACTION_TAKEN
if mark_resolved:
complaint.status = ComplaintStatus.RESOLVED
complaint.resolved_at = timezone.now()
complaint.resolved_by = request.user
complaint.save()
# Create update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="resolution",
message=f"Resolution added{' and complaint marked as resolved' if mark_resolved else ''}",
created_by=request.user,
metadata={
"resolution_category": ComplaintResolutionCategory.FULL_ACTION_TAKEN,
"mark_resolved": mark_resolved,
},
)
# Log audit
AuditService.log_from_request(
event_type="resolution_saved",
description=f"Resolution saved{' and complaint resolved' if mark_resolved else ''}",
request=request,
content_object=complaint,
metadata={"mark_resolved": mark_resolved},
)
return Response(
{
"success": True,
"message": f"Resolution saved successfully{' and complaint marked as resolved' if mark_resolved else ''}",
"complaint_id": str(complaint.id),
"status": complaint.status,
}
)
@action(detail=True, methods=["post"])
def convert_to_appreciation(self, request, pk=None):
"""
Convert complaint to appreciation.
Creates an Appreciation record from a complaint marked as 'appreciation' type.
Maps complaint data to appreciation fields and links both records.
Optionally closes the complaint after conversion.
"""
complaint = self.get_object()
# Check if complaint is in active status
if not complaint.is_active_status:
return Response(
{
"error": f"Cannot convert complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved."
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if complaint is appreciation type
if complaint.complaint_type != "appreciation":
return Response(
{"error": "Only appreciation-type complaints can be converted to appreciations"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if already converted
if complaint.metadata.get("appreciation_id"):
return Response(
{"error": "This complaint has already been converted to an appreciation"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get form data
recipient_type = request.data.get("recipient_type", "user") # 'user' or 'physician'
recipient_id = request.data.get("recipient_id")
category_id = request.data.get("category_id")
message_en = request.data.get("message_en", complaint.description)
message_ar = request.data.get("message_ar", complaint.short_description_ar or "")
visibility = request.data.get("visibility", "private")
is_anonymous = request.data.get("is_anonymous", True)
close_complaint = request.data.get("close_complaint", False)
# Validate recipient
from django.contrib.contenttypes.models import ContentType
if recipient_type == "user":
from apps.accounts.models import User
try:
recipient_user = User.objects.get(id=recipient_id)
recipient_content_type = ContentType.objects.get_for_model(User)
recipient_object_id = recipient_user.id
except User.DoesNotExist:
return Response({"error": "Recipient user not found"}, status=status.HTTP_404_NOT_FOUND)
elif recipient_type == "physician":
from apps.physicians.models import Physician
try:
recipient_physician = Physician.objects.get(id=recipient_id)
recipient_content_type = ContentType.objects.get_for_model(Physician)
recipient_object_id = recipient_physician.id
except Physician.DoesNotExist:
return Response({"error": "Recipient physician not found"}, status=status.HTTP_404_NOT_FOUND)
else:
return Response(
{"error": 'Invalid recipient_type. Must be "user" or "physician"'}, status=status.HTTP_400_BAD_REQUEST
)
# Validate category
from apps.appreciation.models import AppreciationCategory
try:
category = AppreciationCategory.objects.get(id=category_id)
except AppreciationCategory.DoesNotExist:
return Response({"error": "Appreciation category not found"}, status=status.HTTP_404_NOT_FOUND)
# Determine sender (patient or anonymous)
sender = None
if not is_anonymous and complaint.patient and complaint.patient.user:
sender = complaint.patient.user
# Create Appreciation
from apps.appreciation.models import Appreciation
appreciation = Appreciation.objects.create(
sender=sender,
recipient_content_type=recipient_content_type,
recipient_object_id=recipient_object_id,
hospital=complaint.hospital,
department=complaint.department,
category=category,
message_en=message_en,
message_ar=message_ar,
visibility=visibility,
status=Appreciation.AppreciationStatus.DRAFT,
is_anonymous=is_anonymous,
metadata={
"source_complaint_id": str(complaint.id),
"source_complaint_title": complaint.title,
"converted_from_complaint": True,
"converted_by": str(request.user.id),
"converted_at": timezone.now().isoformat(),
},
)
# Send appreciation (triggers notification)
appreciation.send()
# Link appreciation to complaint
if not complaint.metadata:
complaint.metadata = {}
complaint.metadata["appreciation_id"] = str(appreciation.id)
complaint.metadata["converted_to_appreciation"] = True
complaint.metadata["converted_to_appreciation_at"] = timezone.now().isoformat()
complaint.metadata["converted_by"] = str(request.user.id)
complaint.save(update_fields=["metadata"])
# Close complaint if requested
complaint_closed = False
if close_complaint:
complaint.status = "closed"
complaint.closed_at = timezone.now()
complaint.closed_by = request.user
complaint.save(update_fields=["status", "closed_at", "closed_by"])
complaint_closed = True
# Create status update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="status_change",
message="Complaint closed after converting to appreciation",
created_by=request.user,
old_status="open",
new_status="closed",
)
# Create conversion update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Converted to appreciation (Appreciation #{appreciation.id})",
created_by=request.user,
metadata={
"appreciation_id": str(appreciation.id),
"converted_from_complaint": True,
"close_complaint": close_complaint,
},
)
# Log audit
AuditService.log_from_request(
event_type="complaint_converted_to_appreciation",
description=f"Complaint converted to appreciation: {appreciation.message_en[:100]}",
request=request,
content_object=complaint,
metadata={
"appreciation_id": str(appreciation.id),
"close_complaint": close_complaint,
"is_anonymous": is_anonymous,
},
)
# Build appreciation URL
from django.contrib.sites.shortcuts import get_current_site
site = get_current_site(request)
appreciation_url = f"https://{site.domain}/appreciations/{appreciation.id}/"
return Response(
{
"success": True,
"message": "Complaint successfully converted to appreciation",
"appreciation_id": str(appreciation.id),
"appreciation_url": appreciation_url,
"complaint_closed": complaint_closed,
},
status=status.HTTP_201_CREATED,
)
@action(detail=True, methods=["post"])
def send_resolution_notification(self, request, pk=None):
"""
Send resolution notification to patient.
Sends email notification to patient with resolution details.
Optionally sends SMS if phone number is available.
Creates ComplaintUpdate entry and logs audit trail.
"""
complaint = self.get_object()
# Check if complaint is resolved
if complaint.status != "resolved":
return Response(
{"error": "Can only send resolution notification for resolved complaints"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if resolution exists
if not complaint.resolution:
return Response(
{"error": "Complaint must have resolution details before sending notification"},
status=status.HTTP_400_BAD_REQUEST,
)
# Determine recipient (patient or contact)
recipient_email = None
recipient_phone = None
recipient_name = None
# Try patient first
if complaint.patient:
if complaint.patient.email:
recipient_email = complaint.patient.email
if complaint.patient.phone:
recipient_phone = complaint.patient.phone
recipient_name = complaint.patient.get_full_name()
# Fall back to contact info
if not recipient_email:
recipient_email = complaint.contact_email
if not recipient_name:
recipient_name = complaint.contact_name
if not recipient_phone:
recipient_phone = complaint.contact_phone
# Validate at least email is available
if not recipient_email:
return Response(
{"error": "No email address found for patient or contact"}, status=status.HTTP_400_BAD_REQUEST
)
# Build email subject and body
subject = f"Complaint Resolution - #{complaint.id}"
# Build email body
email_body = f"""
Dear {recipient_name},
We are pleased to inform you that your complaint has been resolved.
COMPLAINT DETAILS:
----------------
Reference: #{complaint.id}
Title: {complaint.title}
Status: {complaint.get_status_display()}
RESOLUTION:
-----------
Category: {complaint.get_resolution_category_display()}
{complaint.resolution}
"""
# Add additional context if available
if complaint.resolved_by:
email_body += f"""
Resolved by: {complaint.resolved_by.get_full_name()}
Resolved at: {complaint.resolved_at.strftime("%Y-%m-%d %H:%M")}
"""
email_body += f"""
If you have any further questions or concerns about this resolution,
please don't hesitate to contact us.
Thank you for your patience and for giving us the opportunity to address your concerns.
---
This is an automated message from PX360 Complaint Management System.
"""
# Send email using NotificationService
from apps.notifications.services import NotificationService, get_email_header_html
try:
notification_log = NotificationService.send_email(
email=recipient_email,
subject=subject,
message=email_body,
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Complaint Resolution</h2>
<p style="margin: 0 0 12px 0;">Dear {recipient_name},</p>
<p style="margin: 0 0 12px 0;">We are pleased to inform you that your complaint has been resolved.</p>
<table style="width: 100%; border-collapse: collapse; margin: 0 0 12px 0;">
<tr><td style="padding: 4px 0; color: #6b7280; width: 120px;">Reference:</td><td style="padding: 4px 0;"><strong>#{complaint.id}</strong></td></tr>
<tr><td style="padding: 4px 0; color: #6b7280;">Title:</td><td style="padding: 4px 0;">{complaint.title}</td></tr>
<tr><td style="padding: 4px 0; color: #6b7280;">Category:</td><td style="padding: 4px 0;">{complaint.get_resolution_category_display()}</td></tr>
</table>
<div style="background: #f9fafb; padding: 12px; border-radius: 6px; margin: 0 0 12px 0;">
<p style="margin: 0;">{complaint.resolution}</p>
</div>
<p style="margin: 0 0 12px 0; color: #6b7280;">If you have any further questions or concerns, please don't hesitate to contact us.</p>
<p style="margin: 0; color: #6b7280;">Thank you for your patience and for giving us the opportunity to address your concerns.</p>
</div>
</div>
""",
related_object=complaint,
metadata={
"notification_type": "resolution_notification",
"recipient_name": recipient_name,
"recipient_phone": recipient_phone,
"sender_id": str(request.user.id),
"resolution_category": complaint.resolution_category,
},
)
except Exception as e:
return Response({"error": f"Failed to send email: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Optionally send SMS if phone is available
sms_sent = False
if recipient_phone:
try:
# Build SMS message (shorter)
sms_message = f"PX360: Your complaint #{complaint.id} has been resolved. Resolution Category: {complaint.get_resolution_category_display()}. Check your email for details."
# Send SMS (if SMS service is configured)
# This is a placeholder - actual SMS sending depends on your SMS provider
sms_sent = True # Set to True if SMS is actually sent
if sms_sent:
# Log SMS in metadata
complaint.metadata["resolution_sms_sent_at"] = timezone.now().isoformat()
complaint.metadata["resolution_sms_sent_to"] = recipient_phone
complaint.save(update_fields=["metadata"])
except Exception as e:
# Log error but don't fail the operation
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send SMS: {e}")
# Create ComplaintUpdate entry
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="communication",
message=f"Resolution notification sent to {recipient_name}",
created_by=request.user,
metadata={
"notification_type": "resolution_notification",
"recipient_name": recipient_name,
"recipient_email": recipient_email,
"notification_log_id": str(notification_log.id) if notification_log else None,
"sms_sent": sms_sent,
},
)
# Log audit
AuditService.log_from_request(
event_type="resolution_notification_sent",
description=f"Resolution notification sent to {recipient_name}",
request=request,
content_object=complaint,
metadata={
"recipient_name": recipient_name,
"recipient_email": recipient_email,
"recipient_phone": recipient_phone,
"sms_sent": sms_sent,
"resolution_category": complaint.resolution_category,
},
)
return Response(
{
"success": True,
"message": "Resolution notification sent successfully",
"recipient": recipient_name,
"recipient_email": recipient_email,
"sms_sent": sms_sent,
}
)
@action(detail=True, methods=["post"])
def update_taxonomy(self, request, pk=None):
"""
Update the 4-level SHCT taxonomy classification for a complaint.
Allows PX Admins and Hospital Admins to manually correct or update
the AI-generated taxonomy classification.
Required fields:
- domain_id: UUID of the Level 1 Domain (ComplaintCategory)
- category_id: UUID of the Level 2 Category (ComplaintCategory)
- subcategory_id: UUID of the Level 3 Subcategory (ComplaintCategory)
- classification_id: UUID of the Level 4 Classification (ComplaintCategory)
Optional fields:
- note: Optional note explaining the change
"""
complaint = self.get_object()
user = request.user
# Check permissions
if not (user.is_px_admin() or user.is_hospital_admin()):
return Response(
{"error": "Only PX Admins and Hospital Admins can update taxonomy"}, status=status.HTTP_403_FORBIDDEN
)
# Get taxonomy IDs from request
domain_id = request.data.get("domain_id")
category_id = request.data.get("category_id")
subcategory_id = request.data.get("subcategory_id")
classification_id = request.data.get("classification_id")
note = request.data.get("note", "")
# Validate that at least one field is provided
if not any([domain_id, category_id, subcategory_id, classification_id]):
return Response(
{
"error": "At least one taxonomy level (domain_id, category_id, subcategory_id, or classification_id) must be provided"
},
status=status.HTTP_400_BAD_REQUEST,
)
from apps.complaints.models import ComplaintCategory
changes = []
errors = []
# Store old values for logging
old_domain = complaint.domain
old_category = complaint.category
old_subcategory_obj = complaint.subcategory_obj
old_classification_obj = complaint.classification_obj
try:
# Level 1: Domain
if domain_id:
try:
domain = ComplaintCategory.objects.get(
id=domain_id, level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True
)
complaint.domain = domain
changes.append(f"Domain: {old_domain.name_en if old_domain else 'None'} -> {domain.name_en}")
except ComplaintCategory.DoesNotExist:
errors.append(f"Domain with ID {domain_id} not found or not active")
# Level 2: Category (must be child of domain if domain is set)
if category_id:
try:
category_query = ComplaintCategory.objects.filter(
id=category_id, level=ComplaintCategory.LevelChoices.CATEGORY, is_active=True
)
# If domain is set, ensure category is child of domain
if complaint.domain:
category_query = category_query.filter(parent=complaint.domain)
category = category_query.first()
if category:
complaint.category = category
changes.append(
f"Category: {old_category.name_en if old_category else 'None'} -> {category.name_en}"
)
else:
errors.append(
f"Category with ID {category_id} not found, not active, or not under the selected domain"
)
except Exception as e:
errors.append(f"Error setting category: {str(e)}")
# Level 3: Subcategory (must be child of category if category is set)
if subcategory_id:
try:
subcategory_query = ComplaintCategory.objects.filter(
id=subcategory_id, level=ComplaintCategory.LevelChoices.SUBCATEGORY, is_active=True
)
# If category is set, ensure subcategory is child of category
if complaint.category:
subcategory_query = subcategory_query.filter(parent=complaint.category)
subcategory = subcategory_query.first()
if subcategory:
complaint.subcategory_obj = subcategory
complaint.subcategory = subcategory.code or subcategory.name_en
changes.append(
f"Subcategory: {old_subcategory_obj.name_en if old_subcategory_obj else 'None'} -> {subcategory.name_en}"
)
else:
errors.append(
f"Subcategory with ID {subcategory_id} not found, not active, or not under the selected category"
)
except Exception as e:
errors.append(f"Error setting subcategory: {str(e)}")
# Level 4: Classification (must be child of subcategory if subcategory is set)
if classification_id:
try:
classification_query = ComplaintCategory.objects.filter(
id=classification_id, level=ComplaintCategory.LevelChoices.CLASSIFICATION, is_active=True
)
# If subcategory_obj is set, ensure classification is child of subcategory
if complaint.subcategory_obj:
classification_query = classification_query.filter(parent=complaint.subcategory_obj)
classification = classification_query.first()
if classification:
complaint.classification_obj = classification
complaint.classification = classification.code or classification.name_en
changes.append(
f"Classification: {old_classification_obj.name_en if old_classification_obj else 'None'} -> {classification.name_en}"
)
else:
errors.append(
f"Classification with ID {classification_id} not found, not active, or not under the selected subcategory"
)
except Exception as e:
errors.append(f"Error setting classification: {str(e)}")
# If there were errors, return them without saving
if errors:
return Response(
{"error": "Some taxonomy levels could not be updated", "errors": errors, "changes_made": changes},
status=status.HTTP_400_BAD_REQUEST,
)
# Save the complaint
complaint.save(
update_fields=[
"domain",
"category",
"subcategory",
"subcategory_obj",
"classification",
"classification_obj",
]
)
# Create timeline entry
change_message = "Taxonomy updated:\n" + "\n".join(changes)
if note:
change_message += f"\n\nNote: {note}"
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=change_message,
created_by=user,
metadata={"taxonomy_update": True, "changes": changes, "note": note, "updated_by": str(user.id)},
)
# Log audit
AuditService.log_from_request(
event_type="taxonomy_updated",
description=f"Taxonomy updated for complaint: {complaint.title}",
request=request,
content_object=complaint,
metadata={"changes": changes, "note": note, "updated_by": str(user.id)},
)
# Update metadata to reflect manual update
if not complaint.metadata:
complaint.metadata = {}
if "ai_analysis" not in complaint.metadata:
complaint.metadata["ai_analysis"] = {}
complaint.metadata["ai_analysis"]["taxonomy_manually_updated"] = True
complaint.metadata["ai_analysis"]["taxonomy_updated_by"] = str(user.id)
complaint.metadata["ai_analysis"]["taxonomy_updated_at"] = timezone.now().isoformat()
complaint.save(update_fields=["metadata"])
return Response(
{
"success": True,
"message": "Taxonomy updated successfully",
"changes": changes,
"taxonomy": {
"domain": {
"id": str(complaint.domain.id) if complaint.domain else None,
"name_en": complaint.domain.name_en if complaint.domain else None,
"name_ar": complaint.domain.name_ar if complaint.domain else None,
},
"category": {
"id": str(complaint.category.id) if complaint.category else None,
"name_en": complaint.category.name_en if complaint.category else None,
"name_ar": complaint.category.name_ar if complaint.category else None,
},
"subcategory": {
"id": str(complaint.subcategory_obj.id) if complaint.subcategory_obj else None,
"name_en": complaint.subcategory_obj.name_en if complaint.subcategory_obj else None,
"name_ar": complaint.subcategory_obj.name_ar if complaint.subcategory_obj else None,
"code": complaint.subcategory,
},
"classification": {
"id": str(complaint.classification_obj.id) if complaint.classification_obj else None,
"name_en": complaint.classification_obj.name_en if complaint.classification_obj else None,
"name_ar": complaint.classification_obj.name_ar if complaint.classification_obj else None,
"code": complaint.classification,
},
},
}
)
except Exception as e:
logger.error(f"Error updating taxonomy: {str(e)}")
return Response(
{"error": f"Failed to update taxonomy: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=True, methods=["get"])
def taxonomy_options(self, request, pk=None):
"""
Get available taxonomy options for the complaint's hierarchy.
Returns the full SHCT taxonomy hierarchy for building cascading dropdowns.
Includes only active categories.
"""
complaint = self.get_object()
from apps.complaints.models import ComplaintCategory
from django.db.models import Prefetch
# Build the hierarchy
domains = ComplaintCategory.objects.filter(
level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True
).order_by("order", "name_en")
result = []
for domain in domains:
domain_data = {
"id": str(domain.id),
"code": domain.code or domain.name_en.upper(),
"name_en": domain.name_en,
"name_ar": domain.name_ar,
"is_selected": complaint.domain and complaint.domain.id == domain.id,
"categories": [],
}
# Get categories for this domain
categories = ComplaintCategory.objects.filter(
parent=domain, level=ComplaintCategory.LevelChoices.CATEGORY, is_active=True
).order_by("order", "name_en")
for category in categories:
category_data = {
"id": str(category.id),
"code": category.code or category.name_en.upper(),
"name_en": category.name_en,
"name_ar": category.name_ar,
"is_selected": complaint.category and complaint.category.id == category.id,
"subcategories": [],
}
# Get subcategories for this category
subcategories = ComplaintCategory.objects.filter(
parent=category, level=ComplaintCategory.LevelChoices.SUBCATEGORY, is_active=True
).order_by("order", "name_en")
for subcategory in subcategories:
subcategory_data = {
"id": str(subcategory.id),
"code": subcategory.code or subcategory.name_en.upper(),
"name_en": subcategory.name_en,
"name_ar": subcategory.name_ar,
"is_selected": complaint.subcategory_obj and complaint.subcategory_obj.id == subcategory.id,
"classifications": [],
}
# Get classifications for this subcategory
classifications = ComplaintCategory.objects.filter(
parent=subcategory, level=ComplaintCategory.LevelChoices.CLASSIFICATION, is_active=True
).order_by("order", "name_en")
for classification in classifications:
classification_data = {
"id": str(classification.id),
"code": classification.code,
"name_en": classification.name_en,
"name_ar": classification.name_ar,
"is_selected": complaint.classification_obj
and complaint.classification_obj.id == classification.id,
}
subcategory_data["classifications"].append(classification_data)
category_data["subcategories"].append(subcategory_data)
domain_data["categories"].append(category_data)
result.append(domain_data)
return Response(
{
"success": True,
"hierarchy": result,
"current": {
"domain_id": str(complaint.domain.id) if complaint.domain else None,
"category_id": str(complaint.category.id) if complaint.category else None,
"subcategory_id": str(complaint.subcategory_obj.id) if complaint.subcategory_obj else None,
"classification_id": str(complaint.classification_obj.id) if complaint.classification_obj else None,
},
}
)
@action(detail=True, methods=["post"])
def reanalyze_ai(self, request, pk=None):
"""Trigger re-analysis of a complaint with AI (synchronous - waits for result)"""
complaint = self.get_object()
user = request.user
can_reanalyze = user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital)
if not can_reanalyze:
return Response(
{"error": "You do not have permission to re-analyze this complaint"},
status=status.HTTP_403_FORBIDDEN,
)
try:
from apps.core.ai_service import AIService
from apps.complaints.tasks import _apply_complaint_ai_analysis
from apps.complaints.models import ComplaintUpdate
category_name = None
if complaint.category:
category_name = complaint.category.name_en
analysis = AIService.analyze_complaint(
title=complaint.title,
description=complaint.description,
category=category_name,
hospital_id=complaint.hospital.id,
)
emotion_analysis = AIService.analyze_emotion(text=complaint.description)
result = _apply_complaint_ai_analysis(complaint, analysis, emotion_analysis)
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message="AI re-analysis complete.",
created_by=request.user,
)
AuditService.log_from_request(
event_type="complaint_reanalyzed",
description=f"Complaint {complaint.reference_number} re-analyzed with AI",
request=request,
content_object=complaint,
)
emotion_map = {
"anger": "Anger",
"sadness": "Sadness",
"confusion": "Confusion",
"fear": "Fear",
"neutral": "Neutral",
}
badge_map = {
"anger": "danger",
"sadness": "primary",
"confusion": "warning",
"fear": "info",
"neutral": "secondary",
}
return Response(
{
"success": True,
"severity": result.get("severity", ""),
"severity_display": complaint.get_severity_display(),
"priority": result.get("priority", ""),
"priority_display": complaint.get_priority_display(),
"emotion": result.get("emotion", "neutral"),
"emotion_display": emotion_map.get(result.get("emotion", "neutral"), "Neutral"),
"emotion_badge_class": badge_map.get(result.get("emotion", "neutral"), "secondary"),
"emotion_intensity": result.get("emotion_intensity", 0.0),
"emotion_intensity_percent": result.get("emotion_intensity", 0.0) * 100,
"emotion_confidence": result.get("emotion_confidence", 0.0),
"emotion_confidence_percent": result.get("emotion_confidence", 0.0) * 100,
"short_description_en": result.get("short_description_en", ""),
"short_description_ar": result.get("short_description_ar", ""),
"suggested_actions": result.get("suggested_actions", []),
"suggested_action_en": result.get("suggested_action_en", ""),
"suggested_action_ar": result.get("suggested_action_ar", ""),
}
)
except Exception as e:
logger.error(f"AI re-analysis failed for complaint {pk}: {e}")
return Response(
{"success": False, "error": f"AI analysis failed: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class ComplaintAttachmentViewSet(viewsets.ModelViewSet):
"""ViewSet for Complaint Attachments"""
queryset = ComplaintAttachment.objects.all()
serializer_class = ComplaintAttachmentSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ["complaint"]
ordering = ["-created_at"]
def get_queryset(self):
queryset = super().get_queryset().select_related("complaint", "uploaded_by")
user = self.request.user
# Filter based on complaint access
if user.is_px_admin():
return queryset
if user.is_hospital_admin() and user.hospital:
return queryset.filter(complaint__hospital=user.hospital)
if user.hospital:
return queryset.filter(complaint__hospital=user.hospital)
return queryset.none()
class InquiryViewSet(viewsets.ModelViewSet):
"""ViewSet for Inquiries"""
queryset = Inquiry.objects.all()
serializer_class = InquirySerializer
permission_classes = [IsAuthenticated]
filterset_fields = [
"status",
"category",
"source",
"hospital",
"department",
"assigned_to",
"hospital__organization",
]
search_fields = ["subject", "message", "contact_name", "patient__mrn"]
ordering_fields = ["created_at"]
ordering = ["-created_at"]
def perform_create(self, serializer):
"""Auto-set created_by from request.user and trigger AI analysis in background"""
inquiry = serializer.save(created_by=self.request.user)
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_from_request(
event_type="inquiry_created",
description=f"Inquiry created: {inquiry.subject}",
request=self.request,
content_object=inquiry,
metadata={"created_by": str(inquiry.created_by.id) if inquiry.created_by else None},
)
def get_queryset(self):
"""Filter inquiries based on user role"""
queryset = (
super()
.get_queryset()
.select_related("patient", "hospital", "department", "assigned_to", "responded_by", "created_by")
)
user = self.request.user
# PX Admins see all inquiries
if user.is_px_admin():
return queryset
# Source Users see ONLY inquiries THEY created
if hasattr(user, "source_user_profile") and user.source_user_profile.exists():
return queryset.filter(created_by=user)
# Patients see ONLY their own inquiries (if they have user accounts)
if hasattr(user, "patient_profile"):
return queryset.filter(patient__user=user)
# Hospital Admins see inquiries for their hospital
if user.is_hospital_admin() and user.hospital:
return queryset.filter(hospital=user.hospital)
# Department Managers see inquiries for their department
if user.is_department_manager() and user.department:
return queryset.filter(department=user.department)
# Others see inquiries for their hospital
if user.hospital:
return queryset.filter(hospital=user.hospital)
return queryset.none()
@action(detail=True, methods=["post"])
def respond(self, request, pk=None):
"""Respond to inquiry"""
inquiry = self.get_object()
response_text = request.data.get("response")
if not response_text:
return Response({"error": "response is required"}, status=status.HTTP_400_BAD_REQUEST)
inquiry.response = response_text
inquiry.responded_at = timezone.now()
inquiry.responded_by = request.user
inquiry.status = "resolved"
inquiry.save()
return Response({"message": "Response submitted successfully"})
@action(detail=True, methods=["post"])
def generate_ai_response(self, request, pk=None):
"""
Generate AI-powered response for an inquiry in both English and Arabic.
Admin can edit the generated response before sending to the inquirer.
"""
inquiry = self.get_object()
user = request.user
can_generate = (
user.is_px_admin()
or (user.is_hospital_admin() and user.hospital == inquiry.hospital)
or inquiry.assigned_to == user
)
if not can_generate:
return Response(
{"error": "You do not have permission to generate AI response for this inquiry"},
status=status.HTTP_403_FORBIDDEN,
)
try:
from apps.core.ai_service import AIService
ai_description_en = ""
ai_description_ar = ""
if inquiry.metadata and "ai_analysis" in inquiry.metadata:
ai_description_en = inquiry.metadata["ai_analysis"].get("short_description_en", "")
ai_description_ar = inquiry.metadata["ai_analysis"].get("short_description_ar", "")
category_display = inquiry.get_category_display()
hospital_name = inquiry.hospital.name if inquiry.hospital else "Unknown"
dept_response_section = ""
if inquiry.department_response_en or inquiry.department_response_ar:
dept_response_en = inquiry.department_response_en or ""
dept_response_ar = inquiry.department_response_ar or ""
dept_summary_en = inquiry.department_response_summary_en or ""
dept_summary_ar = inquiry.department_response_summary_ar or ""
dept_response_section = f"""
DEPARTMENT/STAFF RESPONSE (use this as the primary basis for your answer):
- English: {dept_summary_en or dept_response_en}
- Arabic: {dept_summary_ar or dept_response_ar}
IMPORTANT: The department has already provided a technical response above. Your task is to transform it into a clear, patient-friendly response. Keep the factual information and actions taken, but rephrase in simple, empathetic language a patient would understand. Do NOT omit any important details from the department response."""
prompt = f"""As a healthcare inquiry response specialist, generate a professional response to this inquiry in BOTH English and Arabic.
INQUIRY DETAILS:
- Subject: {inquiry.subject}
- Message: {inquiry.message}
- Category: {category_display}
- Hospital: {hospital_name}
- Inquirer Name: {inquiry.contact_name or "Unknown"}
AI SUMMARY (for context):
- English: {ai_description_en}
- Arabic: {ai_description_ar}
{dept_response_section}
Based on the above information, generate a professional response that:
1. Acknowledges the inquiry and thanks the inquirer
2. Directly addresses the question/request
3. Provides helpful, accurate information
4. Offers further assistance if needed
5. Uses a professional, empathetic tone
IMPORTANT: Provide the response in BOTH languages as JSON:
{{
"response_en": "The response text in English (2-4 paragraphs)",
"response_ar": "نص الرد بالعربية (2-4 فقرات)"
}}
Ensure both versions convey the same meaning and are professionally written for healthcare settings."""
system_prompt = """You are an expert healthcare inquiry response specialist fluent in both English and Arabic.
Your task is to generate comprehensive, professional responses to patient inquiries in both languages.
Be helpful, accurate, and empathetic. Use professional tone appropriate for healthcare.
Always provide valid JSON output with both response_en and response_ar fields.
Use Modern Standard Arabic (Fusha) - الفصحى."""
ai_response = AIService.chat_completion(
prompt=prompt,
system_prompt=system_prompt,
temperature=0.4,
max_tokens=1500,
response_format="json_object",
)
import json
response_data = json.loads(ai_response)
response_en = response_data.get("response_en", "").strip()
response_ar = response_data.get("response_ar", "").strip()
AuditService.log_from_request(
event_type="ai_inquiry_response_generated",
description=f"AI response generated for inquiry {inquiry.reference_number}",
request=request,
content_object=inquiry,
metadata={
"inquiry_id": str(inquiry.id),
"generated_response_en_length": len(response_en),
"generated_response_ar_length": len(response_ar),
},
)
return Response(
{
"success": True,
"response_en": response_en,
"response_ar": response_ar,
}
)
except Exception as e:
logger.error(f"AI inquiry response generation failed: {e}")
return Response(
{"success": False, "error": f"Failed to generate response: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@action(detail=True, methods=["post"])
def reanalyze_ai(self, request, pk=None):
"""Trigger re-analysis of an inquiry with AI (synchronous - waits for result)"""
inquiry = self.get_object()
user = request.user
can_reanalyze = (
user.is_px_admin()
or (user.is_hospital_admin() and user.hospital == inquiry.hospital)
or inquiry.assigned_to == user
)
if not can_reanalyze:
return Response(
{"error": "You do not have permission to re-analyze this inquiry"},
status=status.HTTP_403_FORBIDDEN,
)
try:
from apps.core.ai_service import AIService, AIServiceError
from apps.complaints.tasks import _apply_inquiry_ai_analysis
from apps.complaints.models import InquiryUpdate
analysis = AIService.analyze_inquiry(
subject=inquiry.subject,
message=inquiry.message,
category=inquiry.get_category_display(),
hospital_id=inquiry.hospital.id,
)
emotion_analysis = AIService.analyze_emotion(text=inquiry.message)
_apply_inquiry_ai_analysis(inquiry, analysis, emotion_analysis)
InquiryUpdate.objects.create(
inquiry=inquiry,
update_type="note",
message="AI re-analysis complete.",
created_by=request.user,
)
AuditService.log_from_request(
event_type="inquiry_reanalyzed",
description=f"Inquiry {inquiry.reference_number} re-analyzed with AI",
request=request,
content_object=inquiry,
)
return Response(
{
"success": True,
"priority": inquiry.priority,
"priority_display": inquiry.get_priority_display(),
"emotion": emotion_analysis.get("emotion", "neutral"),
"emotion_display": {
"anger": "Anger",
"sadness": "Sadness",
"confusion": "Confusion",
"fear": "Fear",
"neutral": "Neutral",
}.get(emotion_analysis.get("emotion", "neutral"), "Neutral"),
"emotion_badge_class": {
"anger": "danger",
"sadness": "primary",
"confusion": "warning",
"fear": "info",
"neutral": "secondary",
}.get(emotion_analysis.get("emotion", "neutral"), "secondary"),
"emotion_intensity": emotion_analysis.get("intensity", 0.0),
"emotion_intensity_percent": emotion_analysis.get("intensity", 0.0) * 100,
"emotion_confidence": emotion_analysis.get("confidence", 0.0),
"emotion_confidence_percent": emotion_analysis.get("confidence", 0.0) * 100,
"short_description_en": analysis.get("short_description_en", ""),
"short_description_ar": analysis.get("short_description_ar", ""),
}
)
except Exception as e:
logger.error(f"AI re-analysis failed for inquiry {pk}: {e}")
return Response(
{"success": False, "error": f"AI analysis failed: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class ComplaintPRInteractionViewSet(viewsets.ModelViewSet):
"""ViewSet for PR Interactions"""
queryset = ComplaintPRInteraction.objects.all()
serializer_class = ComplaintPRInteractionSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ["complaint", "contact_method", "procedure_explained", "pr_staff"]
ordering = ["-contact_date"]
def get_queryset(self):
queryset = super().get_queryset().select_related("complaint", "pr_staff", "created_by")
user = self.request.user
# Filter based on complaint access
if user.is_px_admin():
return queryset
if user.is_hospital_admin() and user.hospital:
return queryset.filter(complaint__hospital=user.hospital)
if user.hospital:
return queryset.filter(complaint__hospital=user.hospital)
return queryset.none()
def perform_create(self, serializer):
"""Auto-set created_by from request.user"""
interaction = serializer.save(created_by=self.request.user)
# Create complaint update
ComplaintUpdate.objects.create(
complaint=interaction.complaint,
update_type="note",
message=f"PR Interaction recorded: Contact via {interaction.get_contact_method_display()}",
created_by=self.request.user,
metadata={
"interaction_id": str(interaction.id),
"contact_method": interaction.contact_method,
"procedure_explained": interaction.procedure_explained,
},
)
AuditService.log_from_request(
event_type="pr_interaction_created",
description=f"PR Interaction recorded for complaint: {interaction.complaint.title}",
request=self.request,
content_object=interaction,
metadata={"complaint_id": str(interaction.complaint.id), "contact_method": interaction.contact_method},
)
class ComplaintMeetingViewSet(viewsets.ModelViewSet):
"""ViewSet for Complaint Meetings"""
queryset = ComplaintMeeting.objects.all()
serializer_class = ComplaintMeetingSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ["complaint", "meeting_type"]
ordering = ["-meeting_date"]
def get_queryset(self):
queryset = super().get_queryset().select_related("complaint", "created_by")
user = self.request.user
# Filter based on complaint access
if user.is_px_admin():
return queryset
if user.is_hospital_admin() and user.hospital:
return queryset.filter(complaint__hospital=user.hospital)
if user.hospital:
return queryset.filter(complaint__hospital=user.hospital)
return queryset.none()
def perform_create(self, serializer):
"""Auto-set created_by from request.user"""
meeting = serializer.save(created_by=self.request.user)
# Create complaint update
ComplaintUpdate.objects.create(
complaint=meeting.complaint,
update_type="note",
message=f"Meeting recorded: {meeting.get_meeting_type_display()} - {meeting.outcome[:100] if meeting.outcome else ''}",
created_by=self.request.user,
metadata={"meeting_id": str(meeting.id), "meeting_type": meeting.meeting_type},
)
# If outcome is provided, consider it as resolution
if meeting.outcome and meeting.complaint.status not in ["resolved", "closed"]:
meeting.complaint.status = "resolved"
meeting.complaint.resolution = meeting.outcome
meeting.complaint.resolved_at = timezone.now()
meeting.complaint.resolved_by = self.request.user
meeting.complaint.save(update_fields=["status", "resolution", "resolved_at", "resolved_by"])
# Create status update
ComplaintUpdate.objects.create(
complaint=meeting.complaint,
update_type="status_change",
message=f"Complaint resolved through meeting",
created_by=self.request.user,
old_status="in_progress",
new_status="resolved",
)
AuditService.log_from_request(
event_type="meeting_created",
description=f"Complaint Meeting recorded for: {meeting.complaint.title}",
request=self.request,
content_object=meeting,
metadata={"complaint_id": str(meeting.complaint.id), "meeting_type": meeting.meeting_type},
)
# Public views (no authentication required)
from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse
from django.views.decorators.http import require_GET
from django.views.decorators.csrf import csrf_exempt
def api_locations(request):
"""
API endpoint to get all locations for complaint form.
Returns JSON list of all locations ordered by English name.
Public endpoint (no authentication required).
"""
from apps.organizations.models import LegacyLocation
locations = LegacyLocation.objects.all().order_by("name_en")
locations_list = [
{
"id": loc.id,
"name": str(loc), # Uses __str__ which prefers English name
}
for loc in locations
]
return JsonResponse({"success": True, "locations": locations_list, "count": len(locations_list)})
@require_GET
def api_sections(request, location_id):
"""
API endpoint to get sections for a specific location.
Returns JSON list of main sections that have subsections
for given location.
Public endpoint (no authentication required).
"""
from apps.organizations.models import LegacyMainSection, LegacySubSection
# Get available sections that have subsections for this location
available_section_ids = (
LegacySubSection.objects.filter(location_id=location_id).values_list("main_section_id", flat=True).distinct()
)
sections = LegacyMainSection.objects.filter(id__in=available_section_ids).order_by("name_en")
sections_list = [
{
"id": section.id,
"name": str(section), # Uses __str__ which prefers English name
}
for section in sections
]
return JsonResponse(
{"success": True, "location_id": location_id, "sections": sections_list, "count": len(sections_list)}
)
@require_GET
def api_subsections(request, location_id, section_id):
"""
API endpoint to get subsections for a specific location and section.
Returns JSON list of subsections for given location and section.
Public endpoint (no authentication required).
"""
from apps.organizations.models import LegacySubSection
subsections = LegacySubSection.objects.filter(location_id=location_id, main_section_id=section_id).order_by("name_en")
subsections_list = [
{
"id": sub.internal_id, # SubSection uses internal_id as primary key
"name": str(sub), # Uses __str__ which prefers English name
}
for sub in subsections
]
return JsonResponse(
{
"success": True,
"location_id": location_id,
"section_id": section_id,
"subsections": subsections_list,
"count": len(subsections_list),
}
)
@require_GET
def api_departments(request, hospital_id):
"""
API endpoint to get departments for a specific hospital.
Returns JSON list of departments for given hospital.
Public endpoint (no authentication required).
"""
from apps.organizations.models import Department
departments = Department.objects.filter(hospital_id=hospital_id, status="active").order_by("name")
departments_list = [
{
"id": dept.id,
"name": dept.name, # Department model has 'name' field, not name_en
}
for dept in departments
]
return JsonResponse(
{"success": True, "hospital_id": hospital_id, "departments": departments_list, "count": len(departments_list)}
)
def complaint_explanation_pdf(request, complaint_id, token):
"""Token-based PDF download for the explanation page (no login required)."""
from .models import ComplaintExplanation
complaint = get_object_or_404(Complaint, id=complaint_id)
explanation = get_object_or_404(ComplaintExplanation, complaint=complaint, token=token)
import io
import base64
from PIL import Image as PILImage
from django.conf import settings
from django.template.loader import render_to_string
from weasyprint import HTML
logo_path = None
try:
logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png")
logo_img.thumbnail((600, 600), PILImage.LANCZOS)
buf = io.BytesIO()
logo_img.save(buf, format="PNG", optimize=True)
logo_path = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
pass
staff_name = explanation.staff.get_full_name() if explanation.staff else ""
staff_title = explanation.staff.job_title if explanation.staff else ""
dept_name = ""
if explanation.staff and explanation.staff.department:
dept_name = (
explanation.staff.department.name_ar
or explanation.staff.department.name_en
or explanation.staff.department.name
)
from datetime import datetime
html_string = render_to_string(
"complaints/complaint_explanation_pdf.html",
{
"complaint": complaint,
"logo_path": logo_path,
"staff_name": staff_name,
"staff_title": staff_title,
"department_name": dept_name,
"sent_date": (explanation.email_sent_at or complaint.created_at).strftime("%Y/%m/%d %I:%M %p") if (explanation.email_sent_at or complaint.created_at) else "",
},
)
pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
from django.http import HttpResponse
response = HttpResponse(pdf_file, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="complaint_{complaint.reference_number}.pdf"'
return response
def complaint_review_pdf(request, complaint_id, token):
"""Token-based PDF download for the review page — shows staff Q&A (no login required)."""
from .models import ComplaintExplanation, ChampionInvestigation, InvestigationAnswer
complaint = get_object_or_404(Complaint, id=complaint_id)
explanation = get_object_or_404(ComplaintExplanation, complaint=complaint, token=token)
investigation = ChampionInvestigation.objects.filter(
explanation=explanation
).prefetch_related(
"responses__staff", "responses__answers__question"
).first()
if not investigation:
from django.http import HttpResponseNotFound
return HttpResponseNotFound("No investigation found.")
import io
import base64
from PIL import Image as PILImage
from django.conf import settings
from django.template.loader import render_to_string
from weasyprint import HTML
logo_path = None
try:
logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png")
logo_img.thumbnail((600, 600), PILImage.LANCZOS)
buf = io.BytesIO()
logo_img.save(buf, format="PNG", optimize=True)
logo_path = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
pass
staff_data = []
for resp in investigation.responses.all().select_related("staff"):
qa_pairs = []
for ans in resp.answers.all().select_related("question"):
qa_pairs.append({
"question": ans.question.question_text,
"question_type": ans.question.question_type,
"answer": ans.answer_text,
})
staff_data.append({
"staff": resp.staff,
"is_completed": resp.is_completed,
"completed_at": resp.completed_at,
"qa_pairs": qa_pairs,
})
html_string = render_to_string("complaints/complaint_review_pdf.html", {
"complaint": complaint,
"logo_path": logo_path,
"investigation": investigation,
"staff_data": staff_data,
})
pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
from django.http import HttpResponse
response = HttpResponse(pdf_file, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="review_{complaint.reference_number}.pdf"'
return response
def _hospital_departments(complaint):
"""Active departments in the complaint's hospital (for the suggest-dept dropdown)."""
from apps.organizations.models import Department
from django.db.models import Q
if not complaint.hospital:
return Department.objects.none()
return Department.objects.filter(
hospital=complaint.hospital, status="active",
).filter(Q(champion__isnull=False) | Q(manager__isnull=False)).order_by("name")
def complaint_explanation_form(request, complaint_id, token):
"""
Public-facing form for staff to submit explanation.
This view does NOT require authentication.
Validates token and checks if it's still valid (not used).
"""
from django.utils.translation import gettext as _
from .models import ComplaintExplanation, ExplanationAttachment
from apps.notifications.services import NotificationService, get_email_header_html
from django.contrib.sites.shortcuts import get_current_site
# Get complaint
complaint = get_object_or_404(Complaint, id=complaint_id)
# Validate token with staff and department prefetch
# Also prefetch escalation relationship to show original staff explanation to manager
explanation = get_object_or_404(
ComplaintExplanation.objects.select_related("staff", "staff__department", "staff__report_to").prefetch_related(
"escalated_from_staff"
),
complaint=complaint,
token=token,
)
# Get original staff explanation if this is an escalation
original_explanation = None
if hasattr(explanation, "escalated_from_staff"):
# This explanation was created as a result of escalation
# Get the original staff explanation
original_explanation = (
ComplaintExplanation.objects.filter(escalated_to_manager=explanation).select_related("staff").first()
)
# Check if token is already used
if explanation.is_used:
return render(
request,
"complaints/explanation_already_submitted.html",
{"complaint": complaint, "explanation": explanation},
)
if request.method == "POST":
from .models import (
ChampionInvestigation,
InvestigationStatus,
)
action = request.POST.get("action", "")
explanation_text = request.POST.get("explanation", "").strip()
consent_checked = request.POST.get("consent") == "on"
negligence_finding = request.POST.get("negligence_finding", "").strip()
policy_issue_finding = request.POST.get("policy_issue_finding", "").strip()
requires_improvement_project = request.POST.get("requires_improvement_project", "").strip()
improvement_project_note = request.POST.get("improvement_project_note", "").strip()
# Shared base context for re-rendering the form
base_ctx = {
"complaint": complaint,
"explanation": explanation,
"original_explanation": original_explanation,
"final_reply": explanation_text,
"consent_checked": consent_checked,
"negligence_finding": negligence_finding,
"policy_issue_finding": policy_issue_finding,
"requires_improvement_project": requires_improvement_project,
"improvement_project_note": improvement_project_note,
"investigate_url": request.build_absolute_uri(
reverse("complaints:champion_start_investigation", kwargs={
"complaint_id": complaint.id,
"token": explanation.token,
})
),
"hospital_departments": _hospital_departments(complaint),
}
# --- Step 1: Send verification code ---
if action == "send_code":
if not explanation_text:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "error": _("Please write your response first."),
})
if not consent_checked:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "error": _("Please check the acknowledgment box."),
})
if requires_improvement_project == "yes" and not improvement_project_note:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "consent_checked": True,
"error": _("Please describe the required improvement project."),
})
# Get champion/manager contact info
actor = explanation.staff
phone = ""
email = ""
if actor:
phone = actor.phone or ""
email = actor.email or ""
if actor.user:
phone = phone or (actor.user.phone or "")
email = email or (actor.user.email or "")
if not phone and not email:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "consent_checked": True,
"error": _("No phone or email on file. Please contact the PX team."),
})
# Get-or-create a ChampionInvestigation to hold the OTP + assessment fields
investigation, _ = ChampionInvestigation.objects.get_or_create(
explanation=explanation,
complaint=complaint,
defaults={
"champion": actor,
"status": InvestigationStatus.DIRECT_REPLY_IN_PROGRESS,
"final_reply": explanation_text,
},
)
investigation.final_reply = explanation_text
investigation.status = InvestigationStatus.DIRECT_REPLY_IN_PROGRESS
import random
code = f"{random.randint(0, 999999):06d}"
investigation.otp_code = code
investigation.otp_sent_at = timezone.now()
investigation.save(update_fields=[
"final_reply", "status", "otp_code", "otp_sent_at",
])
sent_channels = []
if phone:
try:
NotificationService.send_sms(
phone,
f"PX360: Your verification code is {code}. Enter this to submit your response for complaint #{complaint.reference_number}.",
)
sent_channels.append("phone")
except Exception:
pass
if email:
try:
NotificationService.send_email(
email=email,
subject=f"Verification Code - Complaint #{complaint.reference_number}",
message=f"Your verification code is: {code}\n\nEnter this code to submit your response.",
related_object=complaint,
)
sent_channels.append("email")
except Exception:
pass
return render(request, "complaints/explanation_form.html", {
**base_ctx, "consent_checked": True,
"otp_sent": True,
"otp_phone": "phone" in sent_channels,
"otp_email": "email" in sent_channels,
})
# --- Cancel OTP: user clicked "Edit response" — re-enable the form ---
if action == "cancel_otp":
investigation = ChampionInvestigation.objects.filter(
explanation=explanation,
status=InvestigationStatus.DIRECT_REPLY_IN_PROGRESS,
).first()
if investigation:
investigation.otp_code = ""
investigation.otp_sent_at = None
investigation.save(update_fields=["otp_code", "otp_sent_at"])
# Re-render with otp_sent=False; base_ctx preserves all form values
return render(request, "complaints/explanation_form.html", {**base_ctx})
# --- Step 2: Verify code + submit ---
if action == "verify_submit":
entered_code = request.POST.get("otp_code", "").strip()
if not consent_checked:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "otp_sent": True,
"error": _("Please check the acknowledgment box."),
})
try:
investigation = ChampionInvestigation.objects.get(
explanation=explanation,
status=InvestigationStatus.DIRECT_REPLY_IN_PROGRESS,
)
except ChampionInvestigation.DoesNotExist:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "consent_checked": True,
"error": _("No verification code was sent. Please request a new code."),
})
if not investigation.otp_code or not investigation.otp_sent_at:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "consent_checked": True,
"error": _("No verification code was sent. Please request a new code."),
})
from datetime import timedelta
expiry = investigation.otp_sent_at + timedelta(minutes=10)
if timezone.now() > expiry:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "consent_checked": True,
"error": _("Verification code expired. Please request a new code."),
})
if entered_code != investigation.otp_code:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "consent_checked": True, "otp_sent": True,
"error": _("Incorrect verification code. Please try again."),
})
# --- Code verified — finalize the submission ---
investigation.final_reply = explanation_text
investigation.status = InvestigationStatus.REPLY_SUBMITTED
investigation.otp_code = ""
investigation.negligence_finding = negligence_finding
investigation.policy_issue_finding = policy_issue_finding
investigation.requires_improvement_project = requires_improvement_project
investigation.improvement_project_note = improvement_project_note
investigation.save(update_fields=[
"final_reply", "status", "otp_code",
"negligence_finding", "policy_issue_finding",
"requires_improvement_project", "improvement_project_note",
])
explanation.explanation = explanation_text
explanation.is_used = True
explanation.responded_at = timezone.now()
explanation.save(update_fields=["explanation", "is_used", "responded_at"])
# Save attachments
files = request.FILES.getlist("attachments")
for uploaded_file in files:
ExplanationAttachment.objects.create(
explanation=explanation,
file=uploaded_file,
filename=uploaded_file.name,
file_type=uploaded_file.content_type,
file_size=uploaded_file.size,
)
# First-responder-wins WITHIN the same department only.
# Other departments' explanations stay valid (parallel collection).
if explanation.staff and explanation.staff.department_id:
ComplaintExplanation.objects.filter(
complaint=complaint,
is_used=False,
staff__department_id=explanation.staff.department_id,
).exclude(pk=explanation.pk).update(is_used=True)
# Update the linked ComplaintInvolvedDepartment
from apps.complaints.models import ComplaintInvolvedDepartment
involved_dept = investigation.involved_department
if not involved_dept:
if explanation.staff and explanation.staff.department:
involved_dept = ComplaintInvolvedDepartment.objects.filter(
complaint=complaint,
department=explanation.staff.department,
sent=True,
).first()
if not involved_dept:
involved_dept = ComplaintInvolvedDepartment.objects.filter(
complaint=complaint, sent=True,
).first()
if involved_dept:
involved_dept.response_notes = explanation_text
involved_dept.response_notes_en = explanation_text
involved_dept.response_submitted = True
involved_dept.response_submitted_at = timezone.now()
involved_dept.acceptance_status = "acceptable"
involved_dept.accepted_at = timezone.now()
involved_dept.save()
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Department response submitted (verified) from {involved_dept.department.name}",
metadata={
"explanation_id": str(explanation.id),
"staff_id": str(explanation.staff.id) if explanation.staff else None,
"investigation_id": str(investigation.id),
"flow": "direct_reply",
},
)
else:
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="communication",
message=f"Explanation submitted by {explanation.staff}",
metadata={
"explanation_id": str(explanation.id),
"staff_id": str(explanation.staff.id) if explanation.staff else None,
},
)
if requires_improvement_project == "yes":
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Improvement project recommended: {improvement_project_note}",
metadata={
"investigation_id": str(investigation.id),
"flag": "improvement_project",
"negligence": negligence_finding,
"policy_issue": policy_issue_finding,
"note": improvement_project_note,
},
)
# Notify PX team
if complaint.assigned_to and complaint.assigned_to.email:
try:
notify_msg = f"A response has been submitted for complaint {complaint.reference_number}."
if requires_improvement_project == "yes":
notify_msg += f"\n\n*** IMPROVEMENT PROJECT RECOMMENDED ***\n{improvement_project_note}"
NotificationService.send_email(
email=complaint.assigned_to.email,
subject=f"Department Response Received - Complaint #{complaint.reference_number}",
message=notify_msg,
related_object=complaint,
)
except Exception:
pass
return render(
request,
"complaints/explanation_success.html",
{"complaint": complaint, "explanation": explanation, "attachment_count": len(files)},
)
# --- Reject routing (wrong department) ---
if action == "reject_routing":
from apps.complaints.services.complaint_service import (
reject_department_routing,
RoutingRejectionError,
)
from apps.complaints.models import ComplaintInvolvedDepartment
from apps.organizations.models import Department
reason = request.POST.get("rejection_reason", "").strip()
suggested_id = request.POST.get("suggested_department_id", "").strip()
suggested = None
if suggested_id:
suggested = Department.objects.filter(id=suggested_id, hospital=complaint.hospital).first()
if not reason:
return render(request, "complaints/explanation_form.html", {
**base_ctx,
"show_reject_form": True,
"error": _("Please provide a reason for rejecting this routing."),
})
# Resolve the involved department this champion belongs to
involved_dept = None
if explanation.staff and explanation.staff.department_id:
involved_dept = ComplaintInvolvedDepartment.objects.filter(
complaint=complaint, department_id=explanation.staff.department_id,
).first()
if not involved_dept:
involved_dept = ComplaintInvolvedDepartment.objects.filter(
complaint=complaint, sent=True,
).first()
if not involved_dept:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "show_reject_form": True,
"error": _("Could not find the department routing to reject."),
})
try:
reject_department_routing(
involved_dept,
staff=explanation.staff,
reason=reason,
suggested_department=suggested,
)
except RoutingRejectionError as exc:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "show_reject_form": True,
"error": str(exc) if str(exc) else _("This routing can no longer be rejected."),
})
# Consume the token so it can't be reused
explanation.is_used = True
explanation.responded_at = timezone.now()
explanation.save(update_fields=["is_used", "responded_at"])
return render(
request,
"complaints/explanation_routing_rejected.html",
{"complaint": complaint, "explanation": explanation, "involved_dept": involved_dept},
)
# Unknown / missing action — show form with error
return render(request, "complaints/explanation_form.html", {
**base_ctx,
"error": _("Invalid request."),
})
# GET request - display form
from apps.complaints.models import ComplaintInvolvedStaff
accused_staff = list(
ComplaintInvolvedStaff.objects.filter(
complaint=complaint, role=ComplaintInvolvedStaff.RoleChoices.ACCUSED
).select_related("staff")
)
existing_investigation = None
if explanation.is_used:
from apps.complaints.models import ChampionInvestigation
existing_investigation = ChampionInvestigation.objects.filter(
explanation=explanation
).select_related("champion").first()
# Auto-show OTP entry if a direct-reply OTP is still valid (e.g., user reloaded page)
otp_sent_on_get = False
if not explanation.is_used:
from apps.complaints.models import ChampionInvestigation, InvestigationStatus
from datetime import timedelta
existing = ChampionInvestigation.objects.filter(
explanation=explanation,
status=InvestigationStatus.DIRECT_REPLY_IN_PROGRESS,
otp_code__isnull=False,
).exclude(otp_code="").first()
if existing and existing.otp_sent_at and timezone.now() <= existing.otp_sent_at + timedelta(minutes=10):
otp_sent_on_get = True
return render(
request,
"complaints/explanation_form.html",
{
"complaint": complaint,
"explanation": explanation,
"original_explanation": original_explanation,
"accused_staff": accused_staff,
"existing_investigation": existing_investigation,
"otp_sent": otp_sent_on_get,
"hospital_departments": _hospital_departments(complaint),
"investigate_url": request.build_absolute_uri(
reverse("complaints:champion_start_investigation", kwargs={
"complaint_id": complaint.id,
"token": explanation.token,
})
),
},
)
def champion_start_investigation(request, complaint_id, token):
from .models import (
ComplaintExplanation, ComplaintInvolvedStaff,
ChampionInvestigation, InvestigationQuestion, InvestigationResponse,
InvestigationAnswer, ComplaintUpdate,
)
from apps.notifications.services import NotificationService, get_email_header_html
complaint = get_object_or_404(Complaint, id=complaint_id)
explanation = get_object_or_404(
ComplaintExplanation.objects.select_related("staff", "staff__department"),
complaint=complaint, token=token,
)
# Check if there's an investigation still in progress
in_progress = ChampionInvestigation.objects.filter(
explanation=explanation,
status__in=["questions_sent", "answers_received"],
).first()
if in_progress:
# An investigation is active — check if the explanation was marked used
# (shouldn't be, but reset just in case so the champion can access it)
if explanation.is_used:
explanation.is_used = False
explanation.save(update_fields=["is_used"])
return render(request, "complaints/investigation_already_started.html", {
"complaint": complaint, "investigation": in_progress,
})
# If the explanation was used (by a direct reply or a completed investigation round),
# but all investigations are complete (reply_submitted), allow starting a new round.
if explanation.is_used:
# Was it used by a completed investigation? If so, reset for a new round.
completed_investigations = ChampionInvestigation.objects.filter(
explanation=explanation,
status="reply_submitted",
)
if completed_investigations.exists():
explanation.is_used = False
explanation.save(update_fields=["is_used"])
else:
# Used by a direct reply (not investigation) — stay submitted
return render(request, "complaints/explanation_already_submitted.html", {
"complaint": complaint, "explanation": explanation,
})
accused_staff = list(
ComplaintInvolvedStaff.objects.filter(
complaint=complaint, role=ComplaintInvolvedStaff.RoleChoices.ACCUSED
).select_related("staff")
)
# Note: we no longer fall back to showing same-department staff when
# ComplaintInvolvedStaff is empty. The champion must explicitly search
# and add staff via the token-authenticated search endpoint below.
search_url = request.build_absolute_uri(
reverse("complaints:staff_search_for_investigation", kwargs={
"complaint_id": complaint.id,
"token": explanation.token,
})
)
if request.method == "POST":
import secrets
selected_staff_ids = [sid for sid in request.POST.getlist("accused_staff[]") if sid]
# Per-staff questions: field name questions__<staff_id>[] -> list of (text, type) pairs
per_staff_questions = {}
for sid in selected_staff_ids:
qs = [q.strip() for q in request.POST.getlist(f"questions__{sid}[]") if q.strip()]
q_types = request.POST.getlist(f"question_types__{sid}[]")
if qs:
per_staff_questions[sid] = [
(qs[i], q_types[i] if i < len(q_types) else "text")
for i in range(len(qs))
]
if not selected_staff_ids:
return render(request, "complaints/investigation_questions.html", {
"complaint": complaint,
"explanation": explanation,
"accused_staff": accused_staff,
"search_url": search_url,
"error": "Please select at least one staff member.",
})
if not per_staff_questions:
return render(request, "complaints/investigation_questions.html", {
"complaint": complaint,
"explanation": explanation,
"accused_staff": accused_staff,
"search_url": search_url,
"error": "Please add at least one question for each selected staff member.",
})
involved_dept = explanation.linked_involved_department
investigation = ChampionInvestigation.objects.create(
complaint=complaint,
champion=explanation.staff,
involved_department=involved_dept,
explanation=explanation,
status="questions_sent",
)
# Champion context attachments (shown to staff when they respond)
from .models import InvestigationAttachment
for f in request.FILES.getlist("context_attachments"):
InvestigationAttachment.objects.create(
investigation=investigation,
file=f,
filename=f.name,
file_type=f.content_type,
file_size=f.size,
uploaded_by=request.user if request.user.is_authenticated else None,
)
domain = request.get_host()
staff_count = 0
for sid in selected_staff_ids:
qs = per_staff_questions.get(sid) or []
if not qs:
continue
staff_qs = accused_staff
matched = next((s for s in staff_qs if str(s.staff_id) == sid), None)
if not matched:
try:
from apps.organizations.models import Staff
staff_obj = Staff.objects.get(id=sid)
matched = type('obj', (), {'staff': staff_obj, 'staff_id': staff_obj.id})()
except Exception:
continue
staff_member = matched.staff if hasattr(matched, 'staff') else matched
# Persist selected staff to ComplaintInvolvedStaff so they appear on
# the complaint detail page's involved-staff list going forward.
# Idempotent via get_or_create — no duplicates if already involved.
ComplaintInvolvedStaff.objects.get_or_create(
complaint=complaint,
staff=staff_member,
defaults={
"role": ComplaintInvolvedStaff.RoleChoices.ACCUSED,
"notes": "Added during investigation by champion/manager",
},
)
resp_token = secrets.token_urlsafe(32)
inv_response = InvestigationResponse.objects.create(
investigation=investigation,
staff=staff_member,
token=resp_token,
)
# Create THIS staff member's own questions on their response (per-staff)
for i, (q_text, q_type) in enumerate(qs):
q = InvestigationQuestion.objects.create(
investigation=investigation,
response=inv_response,
question_text=q_text,
question_type=q_type,
order=i + 1,
)
InvestigationAnswer.objects.get_or_create(
response=inv_response, question=q, defaults={"answer_text": ""}
)
respond_url = f"https://{domain}/complaints/{complaint.id}/investigate/respond/{resp_token}/"
staff_user = staff_member.user if hasattr(staff_member, 'user') and staff_member.user else None
staff_email = staff_member.email or (staff_user.email if staff_user else None)
# DEV: print the no-login feedback link so it's visible in the console (debug only)
from django.conf import settings as _settings
if _settings.DEBUG:
print(f"[investigation link] {staff_member.get_full_name()} ({staff_email or 'no email'}) -> {respond_url}")
if staff_email:
try:
NotificationService.send_email(
email=staff_email,
subject=f"Feedback Request - Complaint #{complaint.reference_number}",
message=(
f"Dear {staff_member.get_full_name()},\n\n"
f"You have been requested to provide feedback "
f"for complaint #{complaint.reference_number}.\n\n"
f"Please respond at: {respond_url}\n\n"
f"Note: This link can only be used once."
),
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Feedback Request</h2>
<p>Dear <strong>{staff_member.get_full_name()}</strong>,</p>
<p>You have been requested to provide feedback for complaint
<strong>#{complaint.reference_number}</strong>.</p>
<p>The department champion would like your response before proceeding.</p>
<p style="margin-top: 16px;">
<a href="{respond_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Provide Feedback</a>
</p>
<p style="margin-top: 12px; font-size: 12px; color: #6b7280;">Note: This link can only be used once.</p>
</div>
</div>
""",
related_object=complaint,
user=staff_user,
)
inv_response.email_sent_at = timezone.now()
inv_response.save(update_fields=["email_sent_at"])
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Failed to send investigation email to {staff_email}: {e}")
elif staff_user:
from apps.notifications.models import UserNotification
UserNotification.objects.create(
user=staff_user,
title=f"Investigation Questions - Complaint #{complaint.reference_number}",
message=f"You have investigation questions to answer for complaint #{complaint.reference_number}.",
notification_type="system",
content_object=complaint,
)
staff_phone = staff_member.phone or (staff_member.user.phone if hasattr(staff_member, 'user') and staff_member.user else None)
if staff_phone:
try:
NotificationService.send_sms(
staff_phone,
f"You have feedback to provide for complaint #{complaint.reference_number}. Please respond at: {respond_url}",
)
inv_response.sms_sent_at = timezone.now()
inv_response.save(update_fields=["sms_sent_at"])
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Failed to send investigation SMS to {staff_phone}: {e}")
staff_count += 1
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Champion {explanation.staff.get_full_name()} started investigation — questions sent to {staff_count} staff member(s)",
metadata={
"investigation_id": str(investigation.id),
"staff_count": staff_count,
},
)
return render(request, "complaints/investigation_questions.html", {
"complaint": complaint,
"explanation": explanation,
"accused_staff": accused_staff,
"search_url": search_url,
"success": f"Investigation started. Questions sent to {staff_count} staff member(s).",
})
return render(request, "complaints/investigation_questions.html", {
"complaint": complaint,
"explanation": explanation,
"accused_staff": accused_staff,
"search_url": search_url,
})
def staff_search_for_investigation(request, complaint_id, token):
"""
Public (token-authenticated) staff search for the investigation compose page.
GET /complaints/<complaint_id>/investigate/<token>/search-staff/?q=<query>
Returns active staff in the complaint's hospital matching name/employee_id.
Marks staff already linked to the complaint via ComplaintInvolvedStaff so
the JS can render them as already-added (greyed out).
"""
from django.db.models import Q
from django.http import JsonResponse
complaint = get_object_or_404(Complaint, id=complaint_id)
explanation = get_object_or_404(
ComplaintExplanation, complaint=complaint, token=token
)
if explanation.is_used:
return JsonResponse({"results": [], "error": "token expired"}, status=400)
q = request.GET.get("q", "").strip()
if len(q) < 2:
return JsonResponse({"results": []})
from apps.organizations.models import Staff
from .models import ComplaintInvolvedStaff
already_linked = set(
ComplaintInvolvedStaff.objects.filter(complaint=complaint)
.values_list("staff_id", flat=True)
)
qs = (
Staff.objects.filter(hospital=complaint.hospital, status="active")
.filter(
Q(first_name__icontains=q)
| Q(last_name__icontains=q)
| Q(name__icontains=q)
| Q(name_ar__icontains=q)
| Q(employee_id__icontains=q)
| Q(civil_id__icontains=q)
| Q(license_number__icontains=q)
)
.select_related("department")[:20]
)
results = [
{
"id": str(s.id),
"name": s.get_full_name(),
"department_name": s.department.get_localized_name() if s.department else "",
"employee_id": s.employee_id or "",
"already_added": s.id in already_linked,
}
for s in qs
]
return JsonResponse({"results": results})
def staff_investigation_form(request, complaint_id, token):
from .models import (
InvestigationResponse, InvestigationAnswer,
ChampionInvestigation, ComplaintUpdate,
)
from apps.notifications.services import NotificationService, get_email_header_html
complaint = get_object_or_404(Complaint, id=complaint_id)
inv_response = get_object_or_404(
InvestigationResponse.objects.select_related(
"investigation", "investigation__champion", "staff"
).prefetch_related("investigation__questions"),
token=token,
)
if inv_response.investigation.complaint_id != complaint.id:
return render(request, "complaints/investigation_error.html", {
"error": "Invalid link.",
})
if inv_response.is_completed:
return render(request, "complaints/investigation_already_submitted.html", {
"complaint": complaint, "inv_response": inv_response,
})
# Per-staff questions: prefer questions tied to this response; fall back to shared questions
questions = list(inv_response.questions.all().order_by("order"))
if not questions:
questions = list(inv_response.investigation.questions.all().order_by("order"))
if request.method == "POST":
if request.POST.get("consent") != "on":
question_answer_pairs = []
for q in questions:
question_answer_pairs.append({
"question": q,
"existing_answer": "",
})
return render(request, "complaints/investigation_respond.html", {
"complaint": complaint,
"inv_response": inv_response,
"question_answer_pairs": question_answer_pairs,
"error": _("Please check the acknowledgment box before submitting."),
})
for q in questions:
answer_text = request.POST.get(f"question_{q.id}", "").strip()
InvestigationAnswer.objects.update_or_create(
response=inv_response,
question=q,
defaults={"answer_text": answer_text},
)
inv_response.is_completed = True
inv_response.completed_at = timezone.now()
inv_response.save(update_fields=["is_completed", "completed_at"])
# Staff attachments
from .models import InvestigationResponseAttachment
for f in request.FILES.getlist("attachments"):
InvestigationResponseAttachment.objects.create(
response=inv_response,
file=f,
filename=f.name,
file_type=f.content_type,
file_size=f.size,
)
investigation = inv_response.investigation
if investigation.all_responses_received:
investigation.status = "answers_received"
investigation.save(update_fields=["status"])
champion = investigation.champion
champion_user = champion.user if champion and hasattr(champion, 'user') and champion.user else None
champion_email = champion.email if champion else None
domain = request.get_host()
review_url = f"https://{domain}/complaints/{complaint.id}/investigate/review/{investigation.explanation.token}/"
if champion_email:
try:
NotificationService.send_email(
email=champion_email,
subject=f"All Investigation Responses Received - Complaint #{complaint.reference_number}",
message=(
f"Dear {champion.get_full_name()},\n\n"
f"All accused staff have responded to your investigation questions "
f"for complaint #{complaint.reference_number}.\n\n"
f"Please review and submit your final reply: {review_url}"
),
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">All Investigation Responses Received</h2>
<p style="margin: 0 0 12px 0;">Dear {champion.get_full_name()},</p>
<p style="margin: 0 0 12px 0;">All accused staff have responded to your investigation questions for complaint <strong>#{complaint.reference_number}</strong>.</p>
<p style="margin: 0 0 12px 0;">Please review and submit your final reply.</p>
<div style="text-align: center; margin: 20px 0;">
<a href="{review_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Review &amp; Submit Reply</a>
</div>
</div>
</div>
""",
related_object=complaint,
user=champion_user,
)
except Exception:
import logging
logging.getLogger(__name__).error(f"Failed to send investigation review email to {champion_email}")
elif champion_user:
from apps.notifications.models import UserNotification
UserNotification.objects.create(
user=champion_user,
title=f"All Investigation Responses Received - Complaint #{complaint.reference_number}",
message=f"All accused staff have responded. Please review and submit your final reply.",
notification_type="system",
content_object=complaint,
)
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Investigation response submitted by {inv_response.staff.get_full_name()}",
)
return render(request, "complaints/investigation_success.html", {
"complaint": complaint,
"inv_response": inv_response,
})
answers_map = {}
for ans in InvestigationAnswer.objects.filter(response=inv_response).select_related("question"):
answers_map[str(ans.question_id)] = ans.answer_text
question_answer_pairs = []
for q in questions:
question_answer_pairs.append({
"question": q,
"existing_answer": answers_map.get(str(q.id), ""),
})
return render(request, "complaints/investigation_respond.html", {
"complaint": complaint,
"inv_response": inv_response,
"question_answer_pairs": question_answer_pairs,
"context_attachments": inv_response.investigation.attachments.all(),
})
def champion_review_answers(request, complaint_id, token):
from .models import (
ComplaintExplanation, ComplaintInvolvedDepartment,
ChampionInvestigation, ComplaintUpdate,
)
from apps.notifications.services import NotificationService, get_email_header_html
complaint = get_object_or_404(Complaint, id=complaint_id)
explanation = get_object_or_404(
ComplaintExplanation.objects.select_related("staff", "staff__department"),
complaint=complaint, token=token,
)
investigation = ChampionInvestigation.objects.filter(
explanation=explanation
).prefetch_related(
"questions", "responses__staff", "responses__answers__question", "responses__attachments", "attachments"
).first()
if not investigation:
return render(request, "complaints/investigation_error.html", {
"error": "No investigation found for this complaint.",
})
if investigation.status == InvestigationStatus.REPLY_SUBMITTED:
return render(request, "complaints/investigation_already_submitted.html", {
"complaint": complaint, "investigation": investigation,
})
responses = list(investigation.responses.all().select_related("staff"))
# Shared questions (legacy / when not per-staff); each response may override with its own set
questions = list(investigation.questions.filter(response__isnull=True).order_by("order"))
answer_lookup = {}
from .models import InvestigationAnswer
for ans in InvestigationAnswer.objects.filter(
response__investigation=investigation
).select_related("question"):
key = (str(ans.response_id), str(ans.question_id))
answer_lookup[key] = ans.answer_text
staff_data = []
for resp in responses:
# Per-staff questions: prefer this response's own questions; fall back to shared
resp_questions = list(resp.questions.all().order_by("order")) or questions
qa_pairs = []
for q in resp_questions:
key = (str(resp.id), str(q.id))
qa_pairs.append({
"question": q,
"answer": answer_lookup.get(key, ""),
})
staff_data.append({
"response": resp,
"staff": resp.staff,
"is_completed": resp.is_completed,
"qa_pairs": qa_pairs,
})
base_ctx = {
"complaint": complaint,
"explanation": explanation,
"investigation": investigation,
"questions": questions,
"staff_data": staff_data,
}
if request.method == "POST":
action = request.POST.get("action", "")
final_reply = request.POST.get("final_reply", "").strip()
consent_checked = request.POST.get("consent") == "on"
negligence_finding = request.POST.get("negligence_finding", "").strip()
policy_issue_finding = request.POST.get("policy_issue_finding", "").strip()
requires_improvement_project = request.POST.get("requires_improvement_project", "").strip()
improvement_project_note = request.POST.get("improvement_project_note", "").strip()
# --- Step 1: Send verification code ---
if action == "send_code":
if not final_reply:
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "error": "Please write your final reply first.",
})
if not consent_checked:
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "error": _("Please check the acknowledgment box."),
})
if requires_improvement_project == "yes" and not improvement_project_note:
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "consent_checked": True,
"error": _("Please describe the required improvement project."),
})
# Get champion contact info
champion = explanation.staff
phone = ""
email = ""
if champion:
phone = champion.phone or ""
email = champion.email or ""
if champion.user:
phone = phone or (champion.user.phone or "")
email = email or (champion.user.email or "")
if not phone and not email:
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "consent_checked": True,
"error": "No phone or email on file. Please contact the PX team.",
})
import random
code = f"{random.randint(0, 999999):06d}"
investigation.otp_code = code
investigation.otp_sent_at = timezone.now()
investigation.save(update_fields=["otp_code", "otp_sent_at"])
sent_channels = []
if phone:
try:
NotificationService.send_sms(
phone,
f"PX360: Your verification code is {code}. Enter this to submit your response for complaint #{complaint.reference_number}.",
)
sent_channels.append("phone")
except Exception:
pass
if email:
try:
NotificationService.send_email(
email=email,
subject=f"Verification Code - Complaint #{complaint.reference_number}",
message=f"Your verification code is: {code}\n\nEnter this code to submit your response.",
related_object=complaint,
)
sent_channels.append("email")
except Exception:
pass
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "consent_checked": True,
"otp_sent": True,
"otp_phone": "phone" in sent_channels,
"otp_email": "email" in sent_channels,
})
# --- Cancel OTP: user clicked "Edit response" — re-enable the form ---
if action == "cancel_otp":
investigation.otp_code = ""
investigation.otp_sent_at = None
investigation.save(update_fields=["otp_code", "otp_sent_at"])
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "consent_checked": True,
})
# --- Step 2: Verify code + submit ---
if action == "verify_submit":
entered_code = request.POST.get("otp_code", "").strip()
if not consent_checked:
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "otp_sent": True,
"error": _("Please check the acknowledgment box."),
})
# Validate code
if not investigation.otp_code or not investigation.otp_sent_at:
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "consent_checked": True,
"error": "No verification code was sent. Please request a new code.",
})
# Check expiry (10 minutes)
from datetime import timedelta
expiry = investigation.otp_sent_at + timedelta(minutes=10)
if timezone.now() > expiry:
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "consent_checked": True,
"error": "Verification code expired. Please request a new code.",
})
if entered_code != investigation.otp_code:
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "consent_checked": True,
"otp_sent": True,
"error": "Incorrect verification code. Please try again.",
})
# --- Code verified — process the submission ---
investigation.final_reply = final_reply
investigation.status = InvestigationStatus.REPLY_SUBMITTED
investigation.otp_code = ""
investigation.negligence_finding = negligence_finding
investigation.policy_issue_finding = policy_issue_finding
investigation.requires_improvement_project = requires_improvement_project
investigation.improvement_project_note = improvement_project_note
investigation.save(update_fields=[
"final_reply", "status", "otp_code",
"negligence_finding", "policy_issue_finding",
"requires_improvement_project", "improvement_project_note",
])
explanation.explanation = final_reply
explanation.is_used = True
explanation.responded_at = timezone.now()
explanation.save(update_fields=["explanation", "is_used", "responded_at"])
# Save champion's attachments from the review form
from .models import ExplanationAttachment
for f in request.FILES.getlist("attachments"):
ExplanationAttachment.objects.create(
explanation=explanation,
file=f,
filename=f.name,
file_type=f.content_type,
file_size=f.size,
)
# First-responder-wins WITHIN the same department only.
# Other departments' explanations stay valid (parallel collection).
if explanation.staff and explanation.staff.department_id:
ComplaintExplanation.objects.filter(
complaint=complaint,
is_used=False,
staff__department_id=explanation.staff.department_id,
).exclude(pk=explanation.pk).update(is_used=True)
involved_dept = investigation.involved_department or explanation.linked_involved_department
if involved_dept:
involved_dept.response_notes = final_reply
involved_dept.response_notes_en = final_reply
involved_dept.response_submitted = True
involved_dept.response_submitted_at = timezone.now()
involved_dept.acceptance_status = "acceptable"
involved_dept.accepted_at = timezone.now()
involved_dept.save()
if complaint.assigned_to and complaint.assigned_to.email:
try:
notify_msg = f"A response has been submitted for complaint {complaint.reference_number}."
if requires_improvement_project == "yes":
notify_msg += f"\n\n*** IMPROVEMENT PROJECT RECOMMENDED ***\n{improvement_project_note}"
NotificationService.send_email(
email=complaint.assigned_to.email,
subject=f"Department Response Received - Complaint #{complaint.reference_number}",
message=notify_msg,
related_object=complaint,
)
except Exception:
pass
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Department response submitted (verified) from {explanation.staff.get_full_name()}",
metadata={"investigation_id": str(investigation.id), "response_count": len(responses)},
)
if requires_improvement_project == "yes":
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Improvement project recommended: {improvement_project_note}",
metadata={
"investigation_id": str(investigation.id),
"flag": "improvement_project",
"negligence": negligence_finding,
"policy_issue": policy_issue_finding,
"note": improvement_project_note,
},
)
return render(request, "complaints/explanation_success.html", {
"complaint": complaint, "explanation": explanation, "attachment_count": 0,
})
# Unknown action
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply,
"error": "Invalid action.",
})
# GET: show review page (auto-show code entry if OTP was previously sent and still valid)
from datetime import timedelta
show_otp = bool(
investigation.otp_code
and investigation.otp_sent_at
and timezone.now() <= investigation.otp_sent_at + timedelta(minutes=10)
)
return render(request, "complaints/investigation_review.html", {
**base_ctx,
"otp_sent": show_otp,
})
from django.http import HttpResponse
from django.utils.translation import gettext as _
def generate_complaint_pdf(request, pk):
"""
Generate PDF for a complaint using WeasyPrint.
Creates a professionally styled PDF document with all complaint details
including AI analysis, staff assignment, and resolution information.
"""
# Get complaint
complaint = get_object_or_404(Complaint, id=pk)
# Check permissions
user = request.user
if not user.is_authenticated:
return HttpResponse("Unauthorized", status=401)
# Check if user can view this complaint
can_view = False
if user.is_px_admin():
can_view = True
elif user.is_hospital_admin() and user.hospital == complaint.hospital:
can_view = True
elif user.is_department_manager() and user.department == complaint.department:
can_view = True
elif user.hospital == complaint.hospital:
can_view = True
if not can_view:
return HttpResponse("Forbidden", status=403)
# Render HTML template with comprehensive data
from django.template.loader import render_to_string
# Load logo for the letterhead
import io
import base64
from PIL import Image as PILImage
logo_path = None
try:
logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png")
logo_img.thumbnail((600, 600), PILImage.LANCZOS)
_buf = io.BytesIO()
logo_img.save(_buf, format="PNG", optimize=True)
logo_path = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode()
except Exception:
pass
# Get explanations with their acceptance status
explanations = complaint.explanations.all().select_related("staff", "accepted_by").prefetch_related("attachments")
# Get timeline updates
timeline = complaint.updates.all().select_related("created_by")[:20] # Limit to last 20
# Get related PX Actions
from apps.px_action_center.models import PXAction
from django.contrib.contenttypes.models import ContentType
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")[:5]
html_string = render_to_string(
"complaints/complaint_pdf.html",
{
"complaint": complaint,
"explanations": explanations,
"timeline": timeline,
"px_actions": px_actions,
"generated_at": timezone.now(),
"logo_path": logo_path,
},
)
# Generate PDF using WeasyPrint
try:
from weasyprint import HTML
pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
# Create response
response = HttpResponse(pdf_file, content_type="application/pdf")
# Allow PDF to be displayed in iframe (same origin only)
response["X-Frame-Options"] = "SAMEORIGIN"
# Check if view=inline is requested (for iframe display)
view_mode = request.GET.get("view", "download")
if view_mode == "inline":
# Display inline in browser
response["Content-Disposition"] = "inline"
else:
# Download as attachment
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"complaint_{complaint.reference_number}_{timestamp}.pdf"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
# Log audit
AuditService.log_from_request(
event_type="pdf_generated",
description=f"PDF generated for complaint: {complaint.title}",
request=request,
content_object=complaint,
metadata={"complaint_id": str(pk)},
)
return response
except ImportError:
return HttpResponse("WeasyPrint is not installed. Please install it to generate PDFs.", status=500)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error generating PDF for complaint {pk}: {e}")
return HttpResponse(f"Error generating PDF: {str(e)}", status=500)
def generate_complaint_pdf_v2(request, pk):
"""Generate complaint PDF using the Artboard 1 PNG letterhead (v2)."""
complaint = get_object_or_404(Complaint, id=pk)
if not request.user.is_authenticated:
return HttpResponse("Unauthorized", status=401)
if not (
request.user.is_px_admin()
or (request.user.is_hospital_admin() and request.user.hospital == complaint.hospital)
or (request.user.is_department_manager() and request.user.department == complaint.department)
or (request.user.hospital == complaint.hospital)
):
return HttpResponse("Forbidden", status=403)
from django.template.loader import render_to_string
import io
import base64
from PIL import Image as PILImage
logo_path = None
try:
logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png")
logo_img.thumbnail((600, 600), PILImage.LANCZOS)
_buf = io.BytesIO()
logo_img.save(_buf, format="PNG", optimize=True)
logo_path = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode()
except Exception:
pass
def _img_to_data_uri(path):
"""Open an image file and return it as a base64 data URI."""
try:
img = PILImage.open(path)
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return None
# Letterhead: hospital-specific upload → static fallback
letterhead_path = None
if complaint.hospital and complaint.hospital.letterhead:
letterhead_path = _img_to_data_uri(complaint.hospital.letterhead.path)
if not letterhead_path:
letterhead_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "artboard" / "Artboard 1@3x.png")
# Stamp: hospital-specific upload → static fallback
stamp_path = None
if complaint.hospital and complaint.hospital.stamp:
stamp_path = _img_to_data_uri(complaint.hospital.stamp.path)
if not stamp_path:
stamp_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "stamps" / "stamp.png")
explanations = complaint.explanations.all().select_related("staff", "accepted_by").prefetch_related("attachments")
timeline = complaint.updates.all().select_related("created_by")[:20]
from apps.px_action_center.models import PXAction
from django.contrib.contenttypes.models import ContentType
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")[:5]
html_string = render_to_string(
"complaints/complaint_pdf_v2.html",
{
"complaint": complaint,
"explanations": explanations,
"timeline": timeline,
"px_actions": px_actions,
"generated_at": timezone.now(),
"logo_path": logo_path,
"letterhead_path": letterhead_path,
"stamp_path": stamp_path,
},
)
try:
from weasyprint import HTML
pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
response = HttpResponse(pdf_file, content_type="application/pdf")
response["X-Frame-Options"] = "SAMEORIGIN"
view_mode = request.GET.get("view", "download")
if view_mode == "inline":
response["Content-Disposition"] = "inline"
else:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"complaint_{complaint.reference_number}_{timestamp}.pdf"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except ImportError:
return HttpResponse("WeasyPrint is not installed.", status=500)
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Error generating v2 PDF for complaint {pk}: {e}")
return HttpResponse(f"Error generating PDF: {str(e)}", status=500)
def inquiry_explanation_form(request, inquiry_id, token):
"""
Public-facing form for staff to submit response to an inquiry.
Does NOT require authentication. Validates token and checks validity.
"""
from .models import InquiryExplanation, InquiryExplanationAttachment
inquiry = get_object_or_404(Inquiry, id=inquiry_id)
explanation = get_object_or_404(
InquiryExplanation.objects.select_related("staff", "staff__department"),
inquiry=inquiry,
token=token,
)
if explanation.is_used:
return render(
request,
"complaints/inquiry_explanation_already_submitted.html",
{"inquiry": inquiry, "explanation": explanation},
)
if request.method == "POST":
explanation_text = request.POST.get("explanation", "").strip()
if not explanation_text:
return render(
request,
"complaints/inquiry_explanation_form.html",
{
"inquiry": inquiry,
"explanation": explanation,
"error": "Please provide your response.",
},
)
explanation.explanation = explanation_text
explanation.is_used = True
explanation.responded_at = timezone.now()
explanation.save()
files = request.FILES.getlist("attachments")
for uploaded_file in files:
InquiryExplanationAttachment.objects.create(
explanation=explanation,
file=uploaded_file,
filename=uploaded_file.name,
file_type=uploaded_file.content_type,
file_size=uploaded_file.size,
)
return render(
request,
"complaints/inquiry_explanation_success.html",
{"inquiry": inquiry, "explanation": explanation},
)
return render(
request,
"complaints/inquiry_explanation_form.html",
{"inquiry": inquiry, "explanation": explanation},
)
def inquiry_respond_with_token(request, pk, token):
"""
Public-facing form for department staff to submit response to an inquiry.
Does NOT require authentication. Validates token and checks validity.
"""
from apps.core.ai_service import AIService
from apps.notifications.services import NotificationService
from apps.core.utils import build_public_track_url
inquiry = get_object_or_404(Inquiry, pk=pk)
if inquiry.response_token != token or not inquiry.response_token:
return render(request, "complaints/inquiry_response_token_invalid.html", {"inquiry": inquiry})
if inquiry.response_token_used:
return render(request, "complaints/inquiry_response_already_submitted.html", {"inquiry": inquiry})
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:
return render(request, "complaints/inquiry_response_form_token.html", {
"inquiry": inquiry,
"error": "Please enter a response in at least one language.",
})
inquiry.department_response_en = response_en
inquiry.department_response_ar = response_ar
inquiry.department_responded_at = timezone.now()
inquiry.dept_response_is_overdue = False
inquiry.dept_response_acceptance_status = "pending"
inquiry.response_token_used = True
inquiry.save()
# AI summary
try:
import json
prompt = f"""Summarize the following department response to a patient inquiry in 2-3 concise sentences.
Inquiry subject: {inquiry.subject}
Inquiry message: {(inquiry.message or '')[:500]}
Department response: {response[:500]}
Generate JSON with "summary_en" and "summary_ar"."""
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:
pass
# Notify inquirer
track_url = build_public_track_url("inquiry", inquiry.reference_number)
if inquiry.contact_phone:
try:
NotificationService.send_sms(
phone=inquiry.contact_phone,
message=f"PX360: Your inquiry {inquiry.reference_number} has been responded to. View: {track_url}",
related_object=inquiry,
)
except Exception:
pass
if inquiry.contact_email:
try:
NotificationService.send_email(
email=inquiry.contact_email,
subject=f"PX360: Response to Your Inquiry {inquiry.reference_number}",
message=f"Your inquiry has been responded to.\n\nView: {track_url}",
related_object=inquiry,
)
except Exception:
pass
return render(request, "complaints/inquiry_response_success_token.html", {"inquiry": inquiry})
return render(request, "complaints/inquiry_response_form_token.html", {"inquiry": inquiry})
@login_required
def inquiry_pdf(request, pk):
"""Generate a PDF for an inquiry."""
from .models import Inquiry
from apps.core.pdf_utils import generate_letterhead_pdf
obj = get_object_or_404(Inquiry, id=pk)
return generate_letterhead_pdf(
"complaints/inquiry_pdf.html",
{"object": obj},
f"inquiry_{obj.reference_number}.pdf",
)