5232 lines
214 KiB
Python
5232 lines
214 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 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,
|
|
"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"]})
|
|
- 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 a submitted staff explanation
|
|
staff_explanation = complaint.explanations.filter(
|
|
staff=complaint.staff, is_used=True
|
|
).first()
|
|
|
|
if staff_explanation:
|
|
acceptable_explanation = staff_explanation
|
|
explanation_source = "staff"
|
|
else:
|
|
# Try to find a submitted manager explanation (escalated)
|
|
manager_explanation = complaint.explanations.filter(
|
|
is_used=True,
|
|
metadata__is_escalation=True,
|
|
).first()
|
|
|
|
if manager_explanation:
|
|
acceptable_explanation = manager_explanation
|
|
explanation_source = "manager"
|
|
|
|
if not acceptable_explanation:
|
|
return Response(
|
|
{
|
|
"error": "No submitted explanation found. Please request an explanation from the involved staff 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",
|
|
max_tokens=1500,
|
|
)
|
|
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(),
|
|
},
|
|
)
|
|
|
|
# Activate + send appreciation (triggers notification).
|
|
# activate() honors the activation gate (DRAFT -> ACTIVATED) and records
|
|
# who approved it; send() then moves ACTIVATED -> SENT.
|
|
appreciation.activate(activated_by=request.user)
|
|
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._acting_user = request.user
|
|
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_px_management()
|
|
or user.is_px_employee()
|
|
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
|
|
explanation = get_object_or_404(
|
|
ComplaintExplanation.objects.select_related("staff", "staff__department", "staff__report_to"),
|
|
complaint=complaint,
|
|
token=token,
|
|
)
|
|
|
|
original_explanation = None
|
|
|
|
# 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
|
|
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
|
|
|
|
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",
|
|
"negligence_finding", "policy_issue_finding",
|
|
"requires_improvement_project", "improvement_project_note",
|
|
])
|
|
|
|
sent_channels = []
|
|
if phone:
|
|
try:
|
|
sms_log = NotificationService.send_sms(
|
|
phone,
|
|
f"PX360: Your verification code is {code}. Enter this to submit your response for complaint #{complaint.reference_number}.",
|
|
)
|
|
if sms_log and getattr(sms_log, "status", None) == "sent":
|
|
sent_channels.append("phone")
|
|
except Exception:
|
|
pass
|
|
if email:
|
|
try:
|
|
email_log = 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,
|
|
)
|
|
if email_log and getattr(email_log, "status", None) == "sent":
|
|
sent_channels.append("email")
|
|
except Exception:
|
|
pass
|
|
|
|
# Surface a real delivery failure in production. In DEBUG, always proceed so the flow is testable.
|
|
if not sent_channels and not settings.DEBUG:
|
|
return render(request, "complaints/explanation_form.html", {
|
|
**base_ctx, "consent_checked": True,
|
|
"error": _("Could not send the verification code. Please check your contact details or contact the PX team."),
|
|
})
|
|
|
|
otp_ctx = {
|
|
**base_ctx, "consent_checked": True,
|
|
"otp_sent": True,
|
|
"otp_phone": "phone" in sent_channels,
|
|
"otp_email": "email" in sent_channels,
|
|
}
|
|
if settings.DEBUG:
|
|
otp_ctx["dev_otp_code"] = code
|
|
return render(request, "complaints/explanation_form.html", otp_ctx)
|
|
|
|
# --- 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
|
|
draft_ctx = {}
|
|
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
|
|
draft_ctx = {
|
|
"final_reply": existing.final_reply or "",
|
|
"negligence_finding": existing.negligence_finding or "",
|
|
"policy_issue_finding": existing.policy_issue_finding or "",
|
|
"requires_improvement_project": existing.requires_improvement_project or "",
|
|
"improvement_project_note": existing.improvement_project_note or "",
|
|
"consent_checked": 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,
|
|
})
|
|
),
|
|
**draft_ctx,
|
|
},
|
|
)
|
|
|
|
|
|
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 & 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."),
|
|
})
|
|
|
|
# Verification code goes to whoever is actually reviewing. Prefer the
|
|
# logged-in reviewer (champ or manager); fall back to the token owner
|
|
# (explanation.staff) for anonymous / token-only access.
|
|
phone = ""
|
|
email = ""
|
|
reviewer = request.user if getattr(request.user, "is_authenticated", False) else None
|
|
if reviewer:
|
|
email = reviewer.email or ""
|
|
phone = getattr(reviewer, "phone", "") or ""
|
|
sp = getattr(reviewer, "staff_profile", None)
|
|
if sp:
|
|
email = email or sp.email or ""
|
|
phone = phone or sp.phone or ""
|
|
|
|
# Fallback to the token owner (investigator of record)
|
|
if not email and not phone:
|
|
token_owner = explanation.staff
|
|
if token_owner:
|
|
phone = token_owner.phone or ""
|
|
email = token_owner.email or ""
|
|
if token_owner.user:
|
|
phone = phone or (token_owner.user.phone or "")
|
|
email = email or (token_owner.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()
|
|
# Persist the assessment draft so it survives a page reload / return visit
|
|
# (the OTP step locks the form; without persisting, the data is lost on GET).
|
|
investigation.final_reply = final_reply
|
|
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=[
|
|
"otp_code", "otp_sent_at", "final_reply",
|
|
"negligence_finding", "policy_issue_finding",
|
|
"requires_improvement_project", "improvement_project_note",
|
|
])
|
|
|
|
sent_channels = []
|
|
if phone:
|
|
try:
|
|
sms_log = NotificationService.send_sms(
|
|
phone,
|
|
f"PX360: Your verification code is {code}. Enter this to submit your response for complaint #{complaint.reference_number}.",
|
|
)
|
|
if sms_log and getattr(sms_log, "status", None) == "sent":
|
|
sent_channels.append("phone")
|
|
except Exception:
|
|
pass
|
|
if email:
|
|
try:
|
|
email_log = 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,
|
|
)
|
|
if email_log and getattr(email_log, "status", None) == "sent":
|
|
sent_channels.append("email")
|
|
except Exception:
|
|
pass
|
|
|
|
# Surface a real delivery failure in production. In DEBUG, always proceed so the flow is testable.
|
|
if not sent_channels and not settings.DEBUG:
|
|
return render(request, "complaints/investigation_review.html", {
|
|
**base_ctx, "final_reply": final_reply, "consent_checked": True,
|
|
"error": _("Could not send the verification code. Please check your contact details or contact the PX team."),
|
|
})
|
|
|
|
otp_ctx = {
|
|
**base_ctx, "final_reply": final_reply, "consent_checked": True,
|
|
"otp_sent": True,
|
|
"otp_phone": "phone" in sent_channels,
|
|
"otp_email": "email" in sent_channels,
|
|
}
|
|
if settings.DEBUG:
|
|
otp_ctx["dev_otp_code"] = code
|
|
return render(request, "complaints/investigation_review.html", otp_ctx)
|
|
|
|
# --- 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)
|
|
)
|
|
# Restore the persisted assessment draft (so a return visit keeps the data the
|
|
# reviewer already entered before requesting the code). Consent was already
|
|
# captured when the code was sent, so pre-check it while the OTP is pending.
|
|
return render(request, "complaints/investigation_review.html", {
|
|
**base_ctx,
|
|
"otp_sent": show_otp,
|
|
"final_reply": investigation.final_reply,
|
|
"negligence_finding": investigation.negligence_finding,
|
|
"policy_issue_finding": investigation.policy_issue_finding,
|
|
"requires_improvement_project": investigation.requires_improvement_project,
|
|
"improvement_project_note": investigation.improvement_project_note,
|
|
"consent_checked": 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", "requested_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]
|
|
|
|
# QR code encoding the public tracking URL for authenticity verification
|
|
qr_code_path = None
|
|
try:
|
|
import qrcode
|
|
from django.urls import reverse
|
|
|
|
tracking_url = request.build_absolute_uri(
|
|
reverse("complaints:public_complaint_track")
|
|
) + "?reference=" + complaint.reference_number
|
|
qr = qrcode.QRCode(version=1, box_size=4, border=1)
|
|
qr.add_data(tracking_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_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,
|
|
"qr_code_path": qr_code_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.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
|
|
|
|
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",
|
|
)
|