3211 lines
121 KiB
Python
3211 lines
121 KiB
Python
"""
|
|
Complaints views and viewsets
|
|
"""
|
|
|
|
import logging
|
|
|
|
from django.db.models import Q
|
|
from django.shortcuts import get_object_or_404
|
|
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,
|
|
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
|
|
|
|
# PX Admins see all complaints
|
|
if user.is_px_admin():
|
|
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
|
|
|
|
try:
|
|
notification_log = NotificationService.send_email(
|
|
email=recipient_email,
|
|
subject=subject,
|
|
message=email_body,
|
|
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 request_explanation(self, request, pk=None):
|
|
"""
|
|
Request explanation from complaint staff.
|
|
Delegates to ComplaintService for shared logic.
|
|
"""
|
|
complaint = self.get_object()
|
|
|
|
if not complaint.is_active_status:
|
|
return Response(
|
|
{"error": f"Cannot request explanation for complaint with status '{complaint.get_status_display()}'."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if not complaint.staff:
|
|
return Response(
|
|
{"error": "Complaint has no staff assigned to request explanation from"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
existing = ComplaintExplanation.objects.filter(complaint=complaint, staff=complaint.staff).first()
|
|
if existing and existing.is_used:
|
|
return Response(
|
|
{"error": "This staff member has already submitted an explanation"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
staff = complaint.staff
|
|
staff_email = staff.user.email if staff.user and staff.user.email else (staff.email or "")
|
|
|
|
if not staff_email:
|
|
return Response({"error": "Staff member has no email address"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
request_message = request.data.get("request_message", "").strip()
|
|
manager = staff.report_to if staff.report_to else None
|
|
manager_email = (
|
|
manager.user.email
|
|
if manager and manager.user and manager.user.email
|
|
else (manager.email if manager else "")
|
|
)
|
|
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
|
|
site = get_current_site(request)
|
|
domain = site.domain
|
|
|
|
staff_list = [
|
|
{
|
|
"staff": staff,
|
|
"staff_id": str(staff.id),
|
|
"staff_email": staff_email,
|
|
"staff_name": str(staff),
|
|
"department": staff.department.name if staff.department else "",
|
|
"role": "Primary",
|
|
"manager": manager,
|
|
"manager_id": str(manager.id) if manager else None,
|
|
"manager_email": manager_email,
|
|
"manager_name": str(manager) if manager else None,
|
|
}
|
|
]
|
|
|
|
selected_staff_ids = [str(staff.id)]
|
|
selected_manager_ids = [str(manager.id)] if manager else []
|
|
|
|
try:
|
|
result = ComplaintService.request_explanation(
|
|
complaint,
|
|
staff_list,
|
|
selected_staff_ids,
|
|
selected_manager_ids,
|
|
request_message,
|
|
request.user,
|
|
domain,
|
|
request=request,
|
|
)
|
|
except ComplaintServiceError as e:
|
|
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
staff_explanation = ComplaintExplanation.objects.filter(complaint=complaint, staff=staff).first()
|
|
|
|
return Response(
|
|
{
|
|
"success": result["staff_count"] > 0,
|
|
"message": "Explanation request sent successfully",
|
|
"results": result["results"],
|
|
"staff_explanation_id": str(staff_explanation.id) if staff_explanation else None,
|
|
"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
|
|
|
|
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,
|
|
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)
|
|
|
|
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,
|
|
)
|
|
|
|
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)
|
|
|
|
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,
|
|
)
|
|
|
|
explanation.second_reminder_sent_at = now
|
|
explanation.save(update_fields=["second_reminder_sent_at"])
|
|
except Exception as e:
|
|
return Response(
|
|
{"error": f"Failed to send reminder: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
label = "first" if reminder_type == "first" else "second"
|
|
recipient_display = str(explanation.staff)
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="communication",
|
|
message=f"{label.capitalize()} explanation reminder sent to {recipient_display}",
|
|
created_by=request.user,
|
|
metadata={
|
|
"explanation_id": str(explanation.id),
|
|
"staff_id": str(explanation.staff.id),
|
|
"reminder_type": reminder_type,
|
|
},
|
|
)
|
|
|
|
AuditService.log_from_request(
|
|
event_type=f"explanation_{label}_reminder_sent",
|
|
description=f"{label.capitalize()} explanation reminder sent to {recipient_display}",
|
|
request=request,
|
|
content_object=complaint,
|
|
metadata={"explanation_id": str(explanation.id), "staff_id": str(explanation.staff.id)},
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"success": True,
|
|
"message": f"{label.capitalize()} reminder sent to {recipient_display}",
|
|
"explanation_id": str(explanation.id),
|
|
"reminder_type": reminder_type,
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def review_explanation(self, request, pk=None):
|
|
"""
|
|
Review and mark an explanation as acceptable or not acceptable.
|
|
|
|
Allows PX Admins to review submitted explanations and mark them.
|
|
"""
|
|
complaint = self.get_object()
|
|
|
|
# Check permission
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
return Response(
|
|
{"error": "Only PX Admins or Hospital Admins can review explanations"}, status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
|
|
explanation_id = request.data.get("explanation_id")
|
|
acceptance_status = request.data.get("acceptance_status")
|
|
acceptance_notes = request.data.get("acceptance_notes", "")
|
|
|
|
if not explanation_id:
|
|
return Response({"error": "explanation_id is required"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if not acceptance_status:
|
|
return Response(
|
|
{"error": "acceptance_status is required (acceptable or not_acceptable)"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Validate acceptance status
|
|
from .models import ComplaintExplanation
|
|
|
|
valid_statuses = [
|
|
ComplaintExplanation.AcceptanceStatus.ACCEPTABLE,
|
|
ComplaintExplanation.AcceptanceStatus.NOT_ACCEPTABLE,
|
|
]
|
|
if acceptance_status not in valid_statuses:
|
|
return Response(
|
|
{"error": f"Invalid acceptance_status. Must be one of: {valid_statuses}"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Get the explanation
|
|
try:
|
|
explanation = ComplaintExplanation.objects.get(id=explanation_id, complaint=complaint)
|
|
except ComplaintExplanation.DoesNotExist:
|
|
return Response({"error": "Explanation not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
# Check if explanation has been submitted
|
|
if not explanation.is_used:
|
|
return Response(
|
|
{"error": "Cannot review explanation that has not been submitted yet"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Update explanation
|
|
explanation.acceptance_status = acceptance_status
|
|
explanation.accepted_by = request.user
|
|
explanation.accepted_at = timezone.now()
|
|
explanation.acceptance_notes = acceptance_notes
|
|
explanation.save()
|
|
|
|
# Create complaint update
|
|
status_display = (
|
|
"Acceptable" if acceptance_status == ComplaintExplanation.AcceptanceStatus.ACCEPTABLE else "Not Acceptable"
|
|
)
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Explanation from {explanation.staff} marked as {status_display}",
|
|
created_by=request.user,
|
|
metadata={
|
|
"explanation_id": str(explanation.id),
|
|
"staff_id": str(explanation.staff.id) if explanation.staff else None,
|
|
"acceptance_status": acceptance_status,
|
|
"acceptance_notes": acceptance_notes,
|
|
},
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_from_request(
|
|
event_type="explanation_reviewed",
|
|
description=f"Explanation marked as {status_display}",
|
|
request=request,
|
|
content_object=explanation,
|
|
metadata={
|
|
"explanation_id": str(explanation.id),
|
|
"acceptance_status": acceptance_status,
|
|
"acceptance_notes": acceptance_notes,
|
|
},
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"success": True,
|
|
"message": f"Explanation marked as {status_display}",
|
|
"explanation_id": str(explanation.id),
|
|
"acceptance_status": acceptance_status,
|
|
"accepted_at": explanation.accepted_at,
|
|
"accepted_by": request.user.get_full_name(),
|
|
}
|
|
)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def escalate_explanation(self, request, pk=None):
|
|
"""
|
|
Escalate an explanation to the staff's manager.
|
|
|
|
Marks the explanation as not acceptable and sends an explanation request
|
|
to the staff's manager (report_to).
|
|
"""
|
|
complaint = self.get_object()
|
|
# Check permission
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
return Response(
|
|
{"error": "Only PX Admins or Hospital Admins can escalate explanations"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
explanation_id = request.data.get("explanation_id")
|
|
acceptance_notes = request.data.get("acceptance_notes", "")
|
|
|
|
if not explanation_id:
|
|
return Response({"error": "explanation_id is required"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Get the explanation
|
|
try:
|
|
explanation = ComplaintExplanation.objects.select_related("staff", "staff__report_to").get(
|
|
id=explanation_id, complaint=complaint
|
|
)
|
|
except ComplaintExplanation.DoesNotExist:
|
|
return Response({"error": "Explanation not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
# Check if explanation has been submitted
|
|
if not explanation.is_used:
|
|
return Response(
|
|
{"error": "Cannot escalate explanation that has not been submitted yet"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Check if already escalated
|
|
if explanation.escalated_to_manager:
|
|
return Response({"error": "Explanation has already been escalated"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Use fallback chain to find escalation target
|
|
from apps.complaints.services.complaint_service import ComplaintService
|
|
from apps.organizations.models import Staff
|
|
|
|
target_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=explanation.staff)
|
|
|
|
if not target_user:
|
|
return Response(
|
|
{"error": f"No escalation target found (tried: {fallback_path})"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
manager = Staff.objects.filter(user=target_user).first()
|
|
|
|
if not manager:
|
|
return Response(
|
|
{"error": f"Escalation target {target_user.get_full_name()} has no Staff record"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Check if manager already has an explanation request for this complaint
|
|
existing_manager_explanation = ComplaintExplanation.objects.filter(complaint=complaint, staff=manager).first()
|
|
|
|
if existing_manager_explanation:
|
|
return Response(
|
|
{"error": f"Manager {manager.get_full_name()} already has an explanation request for this complaint"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Generate token for manager explanation
|
|
import secrets
|
|
|
|
manager_token = secrets.token_urlsafe(32)
|
|
|
|
request_message = f"Escalated from staff explanation. Staff: {explanation.staff.get_full_name() if explanation.staff else 'Unknown'}. Notes: {acceptance_notes}"
|
|
if fallback_path != "staff.report_to":
|
|
request_message += f" [Escalated via fallback: {fallback_path}]"
|
|
|
|
# Create manager explanation record
|
|
manager_explanation = ComplaintExplanation.objects.create(
|
|
complaint=complaint,
|
|
staff=manager,
|
|
token=manager_token,
|
|
is_used=False,
|
|
requested_by=request.user,
|
|
request_message=request_message,
|
|
submitted_via="email_link",
|
|
email_sent_at=timezone.now(),
|
|
metadata={
|
|
"escalation_fallback_path": fallback_path,
|
|
},
|
|
)
|
|
|
|
# Update original explanation
|
|
explanation.acceptance_status = ComplaintExplanation.AcceptanceStatus.NOT_ACCEPTABLE
|
|
explanation.accepted_by = request.user
|
|
explanation.accepted_at = timezone.now()
|
|
explanation.acceptance_notes = acceptance_notes
|
|
explanation.escalated_to_manager = manager_explanation
|
|
explanation.escalated_at = timezone.now()
|
|
explanation.save()
|
|
|
|
# Send email to manager
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
from apps.notifications.services import NotificationService
|
|
|
|
site = get_current_site(request)
|
|
explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{manager_token}/"
|
|
|
|
manager_email = manager.email or (manager.user.email if manager.user else None)
|
|
|
|
if manager_email:
|
|
subject = f"Escalated Explanation Request - Complaint #{complaint.reference_number}"
|
|
|
|
email_body = f"""Dear {manager.get_full_name()},
|
|
|
|
An explanation submitted by a staff member who reports to you has been marked as not acceptable and escalated to you for further review.
|
|
|
|
STAFF MEMBER:
|
|
------------
|
|
Name: {explanation.staff.get_full_name() if explanation.staff else "Unknown"}
|
|
Employee ID: {explanation.staff.employee_id if explanation.staff else "N/A"}
|
|
Department: {explanation.staff.department.name if explanation.staff and explanation.staff.department else "N/A"}
|
|
|
|
COMPLAINT DETAILS:
|
|
----------------
|
|
Reference: {complaint.reference_number}
|
|
Title: {complaint.title}
|
|
Severity: {complaint.get_severity_display()}
|
|
Priority: {complaint.get_priority_display()}
|
|
|
|
ORIGINAL EXPLANATION (Not Acceptable):
|
|
--------------------------------------
|
|
{explanation.explanation}
|
|
|
|
ESCALATION NOTES:
|
|
-----------------
|
|
{acceptance_notes if acceptance_notes else "No additional notes provided."}
|
|
|
|
PLEASE SUBMIT YOUR EXPLANATION:
|
|
------------------------------
|
|
As the manager, please submit your perspective on this matter:
|
|
{explanation_link}
|
|
|
|
Note: This link can only be used once. After submission, it will expire.
|
|
|
|
---
|
|
This is an automated message from PX360 Complaint Management System.
|
|
"""
|
|
|
|
try:
|
|
NotificationService.send_email(
|
|
email=manager_email,
|
|
subject=subject,
|
|
message=email_body,
|
|
related_object=complaint,
|
|
metadata={
|
|
"notification_type": "escalated_explanation_request",
|
|
"manager_id": str(manager.id),
|
|
"staff_id": str(explanation.staff.id) if explanation.staff else None,
|
|
"complaint_id": str(complaint.id),
|
|
"original_explanation_id": str(explanation.id),
|
|
},
|
|
)
|
|
email_sent = True
|
|
except Exception as e:
|
|
logger.error(f"Failed to send escalation email to manager: {e}")
|
|
email_sent = False
|
|
else:
|
|
email_sent = False
|
|
|
|
# Create complaint update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Explanation from {explanation.staff} marked as Not Acceptable and escalated to manager {manager.get_full_name()}",
|
|
created_by=request.user,
|
|
metadata={
|
|
"explanation_id": str(explanation.id),
|
|
"staff_id": str(explanation.staff.id) if explanation.staff else None,
|
|
"manager_id": str(manager.id),
|
|
"manager_explanation_id": str(manager_explanation.id),
|
|
"acceptance_status": "not_acceptable",
|
|
"acceptance_notes": acceptance_notes,
|
|
"email_sent": email_sent,
|
|
},
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_from_request(
|
|
event_type="explanation_escalated",
|
|
description=f"Explanation escalated to manager {manager.get_full_name()}",
|
|
request=request,
|
|
content_object=explanation,
|
|
metadata={
|
|
"explanation_id": str(explanation.id),
|
|
"manager_id": str(manager.id),
|
|
"manager_explanation_id": str(manager_explanation.id),
|
|
"email_sent": email_sent,
|
|
},
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"success": True,
|
|
"message": f"Explanation escalated to manager {manager.get_full_name()}",
|
|
"explanation_id": str(explanation.id),
|
|
"manager_explanation_id": str(manager_explanation.id),
|
|
"manager_name": manager.get_full_name(),
|
|
"manager_email": manager_email,
|
|
"email_sent": email_sent,
|
|
}
|
|
)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def generate_ai_resolution(self, request, pk=None):
|
|
"""
|
|
Generate AI-powered resolution note based on complaint details and explanations.
|
|
|
|
Analyzes the complaint description, staff explanations, and manager explanations
|
|
to generate a comprehensive resolution note for admin review.
|
|
"""
|
|
complaint = self.get_object()
|
|
|
|
# Check permission - same logic as can_manage_complaint
|
|
user = request.user
|
|
can_generate = (
|
|
user.is_px_admin()
|
|
or (user.is_hospital_admin() and user.hospital == complaint.hospital)
|
|
or (user.is_department_manager() and user.department == complaint.department)
|
|
or complaint.assigned_to == user
|
|
)
|
|
|
|
if not can_generate:
|
|
return Response(
|
|
{"error": "You do not have permission to generate AI resolution for this complaint"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
# Get all used explanations
|
|
explanations = complaint.explanations.filter(is_used=True).select_related("staff")
|
|
|
|
if not explanations.exists():
|
|
return Response(
|
|
{"success": False, "error": "No explanations available to analyze. Please request explanations first."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Build context for AI
|
|
context = {
|
|
"complaint": {
|
|
"title": complaint.title,
|
|
"description": complaint.description,
|
|
"severity": complaint.get_severity_display(),
|
|
"priority": complaint.get_priority_display(),
|
|
"patient_name": complaint.patient.get_full_name() if complaint.patient else "Unknown",
|
|
"department": complaint.department.name if complaint.department else "Unknown",
|
|
},
|
|
"explanations": [],
|
|
}
|
|
|
|
for exp in explanations:
|
|
exp_data = {
|
|
"staff_name": exp.staff.get_full_name() if exp.staff else "Unknown",
|
|
"employee_id": exp.staff.employee_id if exp.staff else "N/A",
|
|
"department": exp.staff.department.name if exp.staff and exp.staff.department else "N/A",
|
|
"explanation": exp.explanation,
|
|
"acceptance_status": exp.get_acceptance_status_display(),
|
|
"submitted_at": exp.responded_at.strftime("%Y-%m-%d %H:%M") if exp.responded_at else "Unknown",
|
|
}
|
|
context["explanations"].append(exp_data)
|
|
|
|
# Call AI service to generate resolution
|
|
try:
|
|
from apps.core.ai_service import AIService
|
|
|
|
# Build prompt
|
|
explanations_text = ""
|
|
for i, exp in enumerate(context["explanations"], 1):
|
|
explanations_text += f"""
|
|
Explanation {i}:
|
|
- Staff: {exp["staff_name"]} (ID: {exp["employee_id"]}, Dept: {exp["department"]})
|
|
- Status: {exp["acceptance_status"]}
|
|
- Submitted: {exp["submitted_at"]}
|
|
- Content: {exp["explanation"]}
|
|
|
|
"""
|
|
|
|
prompt = f"""As a healthcare complaint resolution expert, analyze the following complaint and staff explanations to generate a comprehensive resolution note in BOTH English and Arabic.
|
|
|
|
COMPLAINT DETAILS:
|
|
- Title: {context["complaint"]["title"]}
|
|
- Description: {context["complaint"]["description"]}
|
|
- Severity: {context["complaint"]["severity"]}
|
|
- Priority: {context["complaint"]["priority"]}
|
|
- Patient: {context["complaint"]["patient_name"]}
|
|
- Department: {context["complaint"]["department"]}
|
|
|
|
STAFF EXPLANATIONS:
|
|
{explanations_text}
|
|
|
|
Based on the above information, generate a professional resolution note that:
|
|
1. Summarizes the main issue and root cause
|
|
2. References the key points from staff explanations
|
|
3. States the outcome/decision
|
|
4. Includes any corrective actions taken or planned
|
|
5. Addresses patient concerns
|
|
6. Mentions any follow-up actions
|
|
|
|
The resolution should be written in a professional, empathetic tone suitable for healthcare settings.
|
|
|
|
IMPORTANT: Provide the resolution in BOTH languages as JSON:
|
|
{{
|
|
"resolution_en": "The resolution text in English (3-5 paragraphs)",
|
|
"resolution_ar": "نص القرار بالعربية (3-5 فقرات)"
|
|
}}
|
|
|
|
Ensure both versions convey the same meaning and are professionally written."""
|
|
|
|
system_prompt = """You are an expert healthcare complaint resolution specialist fluent in both English and Arabic.
|
|
Your task is to analyze complaints and staff explanations to generate comprehensive, professional resolution notes in both languages.
|
|
Be objective, empathetic, and thorough. Focus on facts while acknowledging the patient's concerns.
|
|
Write in a professional tone appropriate for medical records in both languages.
|
|
Always provide valid JSON output with both resolution_en and resolution_ar fields."""
|
|
|
|
ai_response = AIService.chat_completion(
|
|
prompt=prompt,
|
|
system_prompt=system_prompt,
|
|
temperature=0.4,
|
|
max_tokens=1500,
|
|
response_format="json_object",
|
|
)
|
|
|
|
# Parse the JSON response
|
|
import json
|
|
|
|
resolution_data = json.loads(ai_response)
|
|
resolution_en = resolution_data.get("resolution_en", "").strip()
|
|
resolution_ar = resolution_data.get("resolution_ar", "").strip()
|
|
|
|
# Log the AI generation
|
|
AuditService.log_from_request(
|
|
event_type="ai_resolution_generated",
|
|
description=f"AI resolution generated for complaint {complaint.reference_number}",
|
|
request=request,
|
|
content_object=complaint,
|
|
metadata={
|
|
"complaint_id": str(complaint.id),
|
|
"explanation_count": explanations.count(),
|
|
"generated_resolution_en_length": len(resolution_en),
|
|
"generated_resolution_ar_length": len(resolution_ar),
|
|
},
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"success": True,
|
|
"resolution_en": resolution_en,
|
|
"resolution_ar": resolution_ar,
|
|
"explanation_count": explanations.count(),
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"AI resolution generation failed: {e}")
|
|
return Response(
|
|
{"success": False, "error": f"Failed to generate resolution: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
@action(detail=True, methods=["get"])
|
|
def generate_resolution_suggestion(self, request, pk=None):
|
|
"""
|
|
Generate AI resolution suggestion based on complaint and acceptable explanation.
|
|
|
|
Uses the staff explanation if acceptable, otherwise uses manager explanation.
|
|
Returns a suggested resolution text that can be edited or used directly.
|
|
"""
|
|
complaint = self.get_object()
|
|
|
|
# Check permission
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
return Response(
|
|
{"error": "Only PX Admins or Hospital Admins can generate resolution suggestions"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
# Find acceptable explanation
|
|
acceptable_explanation = None
|
|
explanation_source = None
|
|
|
|
# First, try to find an acceptable staff explanation
|
|
staff_explanation = complaint.explanations.filter(
|
|
staff=complaint.staff, is_used=True, acceptance_status=ComplaintExplanation.AcceptanceStatus.ACCEPTABLE
|
|
).first()
|
|
|
|
if staff_explanation:
|
|
acceptable_explanation = staff_explanation
|
|
explanation_source = "staff"
|
|
else:
|
|
# Try to find an acceptable manager explanation (escalated)
|
|
manager_explanation = complaint.explanations.filter(
|
|
is_used=True,
|
|
acceptance_status=ComplaintExplanation.AcceptanceStatus.ACCEPTABLE,
|
|
metadata__is_escalation=True,
|
|
).first()
|
|
|
|
if manager_explanation:
|
|
acceptable_explanation = manager_explanation
|
|
explanation_source = "manager"
|
|
|
|
if not acceptable_explanation:
|
|
return Response(
|
|
{
|
|
"error": "No acceptable explanation found. Please review and mark an explanation as acceptable first.",
|
|
"suggestion": None,
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Generate resolution using AI
|
|
try:
|
|
resolution_text = self._generate_ai_resolution(
|
|
complaint=complaint, explanation=acceptable_explanation, source=explanation_source
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"success": True,
|
|
"suggestion": resolution_text,
|
|
"source": explanation_source,
|
|
"source_staff": acceptable_explanation.staff.get_full_name()
|
|
if acceptable_explanation.staff
|
|
else None,
|
|
"explanation_id": str(acceptable_explanation.id),
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate resolution: {e}")
|
|
return Response(
|
|
{"error": "Failed to generate resolution suggestion", "detail": str(e)},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
def _generate_ai_resolution(self, complaint, explanation, source):
|
|
"""
|
|
Generate AI resolution text based on complaint and explanation.
|
|
|
|
This is a stub implementation. Replace with actual AI service call.
|
|
"""
|
|
# Build context for AI
|
|
complaint_details = f"""
|
|
Complaint Title: {complaint.title}
|
|
Complaint Description: {complaint.description}
|
|
Severity: {complaint.get_severity_display()}
|
|
Priority: {complaint.get_priority_display()}
|
|
"""
|
|
|
|
explanation_text = explanation.explanation
|
|
explanation_by = explanation.staff.get_full_name() if explanation.staff else "Unknown"
|
|
|
|
# For now, generate a template-based resolution
|
|
# This should be replaced with actual AI service call
|
|
|
|
resolution = f"""RESOLUTION SUMMARY
|
|
|
|
Based on the complaint filed regarding: {complaint.title}
|
|
|
|
INVESTIGATION FINDINGS:
|
|
After reviewing the complaint and the explanation provided by {explanation_by} ({source}), the following has been determined:
|
|
|
|
{explanation_text}
|
|
|
|
RESOLUTION:
|
|
The matter has been addressed through appropriate channels.
|
|
|
|
ACTIONS TAKEN:
|
|
- The issue has been reviewed and investigated thoroughly
|
|
- Appropriate measures have been implemented to address the concern
|
|
- Steps have been taken to prevent recurrence
|
|
|
|
The complaint is considered resolved."""
|
|
|
|
return resolution
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def save_resolution(self, request, pk=None):
|
|
"""
|
|
Save final resolution for the complaint.
|
|
|
|
Allows user to save an edited or directly generated resolution.
|
|
Optionally updates complaint status to RESOLVED.
|
|
"""
|
|
complaint = self.get_object()
|
|
|
|
# Check permission
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
return Response(
|
|
{"error": "Only PX Admins or Hospital Admins can save resolutions"}, status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
|
|
resolution_text = request.data.get("resolution")
|
|
mark_resolved = request.data.get("mark_resolved", False)
|
|
|
|
if not resolution_text:
|
|
return Response({"error": "Resolution text is required"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Save resolution
|
|
complaint.resolution = resolution_text
|
|
complaint.resolution_category = ComplaintResolutionCategory.FULL_ACTION_TAKEN
|
|
|
|
if mark_resolved:
|
|
complaint.status = ComplaintStatus.RESOLVED
|
|
complaint.resolved_at = timezone.now()
|
|
complaint.resolved_by = request.user
|
|
|
|
complaint.save()
|
|
|
|
# Create update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="resolution",
|
|
message=f"Resolution added{' and complaint marked as resolved' if mark_resolved else ''}",
|
|
created_by=request.user,
|
|
metadata={
|
|
"resolution_category": ComplaintResolutionCategory.FULL_ACTION_TAKEN,
|
|
"mark_resolved": mark_resolved,
|
|
},
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_from_request(
|
|
event_type="resolution_saved",
|
|
description=f"Resolution saved{' and complaint resolved' if mark_resolved else ''}",
|
|
request=request,
|
|
content_object=complaint,
|
|
metadata={"mark_resolved": mark_resolved},
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"success": True,
|
|
"message": f"Resolution saved successfully{' and complaint marked as resolved' if mark_resolved else ''}",
|
|
"complaint_id": str(complaint.id),
|
|
"status": complaint.status,
|
|
}
|
|
)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def convert_to_appreciation(self, request, pk=None):
|
|
"""
|
|
Convert complaint to appreciation.
|
|
|
|
Creates an Appreciation record from a complaint marked as 'appreciation' type.
|
|
Maps complaint data to appreciation fields and links both records.
|
|
Optionally closes the complaint after conversion.
|
|
"""
|
|
complaint = self.get_object()
|
|
|
|
# Check if complaint is in active status
|
|
if not complaint.is_active_status:
|
|
return Response(
|
|
{
|
|
"error": f"Cannot convert complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved."
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Check if complaint is appreciation type
|
|
if complaint.complaint_type != "appreciation":
|
|
return Response(
|
|
{"error": "Only appreciation-type complaints can be converted to appreciations"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Check if already converted
|
|
if complaint.metadata.get("appreciation_id"):
|
|
return Response(
|
|
{"error": "This complaint has already been converted to an appreciation"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Get form data
|
|
recipient_type = request.data.get("recipient_type", "user") # 'user' or 'physician'
|
|
recipient_id = request.data.get("recipient_id")
|
|
category_id = request.data.get("category_id")
|
|
message_en = request.data.get("message_en", complaint.description)
|
|
message_ar = request.data.get("message_ar", complaint.short_description_ar or "")
|
|
visibility = request.data.get("visibility", "private")
|
|
is_anonymous = request.data.get("is_anonymous", True)
|
|
close_complaint = request.data.get("close_complaint", False)
|
|
|
|
# Validate recipient
|
|
from django.contrib.contenttypes.models import ContentType
|
|
|
|
if recipient_type == "user":
|
|
from apps.accounts.models import User
|
|
|
|
try:
|
|
recipient_user = User.objects.get(id=recipient_id)
|
|
recipient_content_type = ContentType.objects.get_for_model(User)
|
|
recipient_object_id = recipient_user.id
|
|
except User.DoesNotExist:
|
|
return Response({"error": "Recipient user not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
elif recipient_type == "physician":
|
|
from apps.physicians.models import Physician
|
|
|
|
try:
|
|
recipient_physician = Physician.objects.get(id=recipient_id)
|
|
recipient_content_type = ContentType.objects.get_for_model(Physician)
|
|
recipient_object_id = recipient_physician.id
|
|
except Physician.DoesNotExist:
|
|
return Response({"error": "Recipient physician not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
else:
|
|
return Response(
|
|
{"error": 'Invalid recipient_type. Must be "user" or "physician"'}, status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
# Validate category
|
|
from apps.appreciation.models import AppreciationCategory
|
|
|
|
try:
|
|
category = AppreciationCategory.objects.get(id=category_id)
|
|
except AppreciationCategory.DoesNotExist:
|
|
return Response({"error": "Appreciation category not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
# Determine sender (patient or anonymous)
|
|
sender = None
|
|
if not is_anonymous and complaint.patient and complaint.patient.user:
|
|
sender = complaint.patient.user
|
|
|
|
# Create Appreciation
|
|
from apps.appreciation.models import Appreciation
|
|
|
|
appreciation = Appreciation.objects.create(
|
|
sender=sender,
|
|
recipient_content_type=recipient_content_type,
|
|
recipient_object_id=recipient_object_id,
|
|
hospital=complaint.hospital,
|
|
department=complaint.department,
|
|
category=category,
|
|
message_en=message_en,
|
|
message_ar=message_ar,
|
|
visibility=visibility,
|
|
status=Appreciation.AppreciationStatus.DRAFT,
|
|
is_anonymous=is_anonymous,
|
|
metadata={
|
|
"source_complaint_id": str(complaint.id),
|
|
"source_complaint_title": complaint.title,
|
|
"converted_from_complaint": True,
|
|
"converted_by": str(request.user.id),
|
|
"converted_at": timezone.now().isoformat(),
|
|
},
|
|
)
|
|
|
|
# Send appreciation (triggers notification)
|
|
appreciation.send()
|
|
|
|
# Link appreciation to complaint
|
|
if not complaint.metadata:
|
|
complaint.metadata = {}
|
|
complaint.metadata["appreciation_id"] = str(appreciation.id)
|
|
complaint.metadata["converted_to_appreciation"] = True
|
|
complaint.metadata["converted_to_appreciation_at"] = timezone.now().isoformat()
|
|
complaint.metadata["converted_by"] = str(request.user.id)
|
|
complaint.save(update_fields=["metadata"])
|
|
|
|
# Close complaint if requested
|
|
complaint_closed = False
|
|
if close_complaint:
|
|
complaint.status = "closed"
|
|
complaint.closed_at = timezone.now()
|
|
complaint.closed_by = request.user
|
|
complaint.save(update_fields=["status", "closed_at", "closed_by"])
|
|
complaint_closed = True
|
|
|
|
# Create status update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="status_change",
|
|
message="Complaint closed after converting to appreciation",
|
|
created_by=request.user,
|
|
old_status="open",
|
|
new_status="closed",
|
|
)
|
|
|
|
# Create conversion update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Converted to appreciation (Appreciation #{appreciation.id})",
|
|
created_by=request.user,
|
|
metadata={
|
|
"appreciation_id": str(appreciation.id),
|
|
"converted_from_complaint": True,
|
|
"close_complaint": close_complaint,
|
|
},
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_from_request(
|
|
event_type="complaint_converted_to_appreciation",
|
|
description=f"Complaint converted to appreciation: {appreciation.message_en[:100]}",
|
|
request=request,
|
|
content_object=complaint,
|
|
metadata={
|
|
"appreciation_id": str(appreciation.id),
|
|
"close_complaint": close_complaint,
|
|
"is_anonymous": is_anonymous,
|
|
},
|
|
)
|
|
|
|
# Build appreciation URL
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
|
|
site = get_current_site(request)
|
|
appreciation_url = f"https://{site.domain}/appreciations/{appreciation.id}/"
|
|
|
|
return Response(
|
|
{
|
|
"success": True,
|
|
"message": "Complaint successfully converted to appreciation",
|
|
"appreciation_id": str(appreciation.id),
|
|
"appreciation_url": appreciation_url,
|
|
"complaint_closed": complaint_closed,
|
|
},
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def send_resolution_notification(self, request, pk=None):
|
|
"""
|
|
Send resolution notification to patient.
|
|
|
|
Sends email notification to patient with resolution details.
|
|
Optionally sends SMS if phone number is available.
|
|
Creates ComplaintUpdate entry and logs audit trail.
|
|
"""
|
|
complaint = self.get_object()
|
|
|
|
# Check if complaint is resolved
|
|
if complaint.status != "resolved":
|
|
return Response(
|
|
{"error": "Can only send resolution notification for resolved complaints"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Check if resolution exists
|
|
if not complaint.resolution:
|
|
return Response(
|
|
{"error": "Complaint must have resolution details before sending notification"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Determine recipient (patient or contact)
|
|
recipient_email = None
|
|
recipient_phone = None
|
|
recipient_name = None
|
|
|
|
# Try patient first
|
|
if complaint.patient:
|
|
if complaint.patient.email:
|
|
recipient_email = complaint.patient.email
|
|
if complaint.patient.phone:
|
|
recipient_phone = complaint.patient.phone
|
|
recipient_name = complaint.patient.get_full_name()
|
|
|
|
# Fall back to contact info
|
|
if not recipient_email:
|
|
recipient_email = complaint.contact_email
|
|
if not recipient_name:
|
|
recipient_name = complaint.contact_name
|
|
if not recipient_phone:
|
|
recipient_phone = complaint.contact_phone
|
|
|
|
# Validate at least email is available
|
|
if not recipient_email:
|
|
return Response(
|
|
{"error": "No email address found for patient or contact"}, status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
# Build email subject and body
|
|
subject = f"Complaint Resolution - #{complaint.id}"
|
|
|
|
# Build email body
|
|
email_body = f"""
|
|
Dear {recipient_name},
|
|
|
|
We are pleased to inform you that your complaint has been resolved.
|
|
|
|
COMPLAINT DETAILS:
|
|
----------------
|
|
Reference: #{complaint.id}
|
|
Title: {complaint.title}
|
|
Status: {complaint.get_status_display()}
|
|
|
|
RESOLUTION:
|
|
-----------
|
|
Category: {complaint.get_resolution_category_display()}
|
|
|
|
{complaint.resolution}
|
|
|
|
"""
|
|
|
|
# Add additional context if available
|
|
if complaint.resolved_by:
|
|
email_body += f"""
|
|
Resolved by: {complaint.resolved_by.get_full_name()}
|
|
Resolved at: {complaint.resolved_at.strftime("%Y-%m-%d %H:%M")}
|
|
"""
|
|
|
|
email_body += f"""
|
|
|
|
If you have any further questions or concerns about this resolution,
|
|
please don't hesitate to contact us.
|
|
|
|
Thank you for your patience and for giving us the opportunity to address your concerns.
|
|
|
|
---
|
|
This is an automated message from PX360 Complaint Management System.
|
|
"""
|
|
|
|
# Send email using NotificationService
|
|
from apps.notifications.services import NotificationService
|
|
|
|
try:
|
|
notification_log = NotificationService.send_email(
|
|
email=recipient_email,
|
|
subject=subject,
|
|
message=email_body,
|
|
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,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
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"""
|
|
inquiry = serializer.save(created_by=self.request.user)
|
|
|
|
AuditService.log_from_request(
|
|
event_type="inquiry_created",
|
|
description=f"Inquiry created: {inquiry.subject}",
|
|
request=self.request,
|
|
content_object=inquiry,
|
|
metadata={"created_by": str(inquiry.created_by.id) if inquiry.created_by else None},
|
|
)
|
|
|
|
def get_queryset(self):
|
|
"""Filter inquiries based on user role"""
|
|
queryset = (
|
|
super()
|
|
.get_queryset()
|
|
.select_related("patient", "hospital", "department", "assigned_to", "responded_by", "created_by")
|
|
)
|
|
|
|
user = self.request.user
|
|
|
|
# PX Admins see all inquiries
|
|
if user.is_px_admin():
|
|
return queryset
|
|
|
|
# Source Users see ONLY inquiries THEY created
|
|
if hasattr(user, "source_user_profile") and user.source_user_profile.exists():
|
|
return queryset.filter(created_by=user)
|
|
|
|
# Patients see ONLY their own inquiries (if they have user accounts)
|
|
if hasattr(user, "patient_profile"):
|
|
return queryset.filter(patient__user=user)
|
|
|
|
# Hospital Admins see inquiries for their hospital
|
|
if user.is_hospital_admin() and user.hospital:
|
|
return queryset.filter(hospital=user.hospital)
|
|
|
|
# Department Managers see inquiries for their department
|
|
if user.is_department_manager() and user.department:
|
|
return queryset.filter(department=user.department)
|
|
|
|
# Others see inquiries for their hospital
|
|
if user.hospital:
|
|
return queryset.filter(hospital=user.hospital)
|
|
|
|
return queryset.none()
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def respond(self, request, pk=None):
|
|
"""Respond to inquiry"""
|
|
inquiry = self.get_object()
|
|
response_text = request.data.get("response")
|
|
|
|
if not response_text:
|
|
return Response({"error": "response is required"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
inquiry.response = response_text
|
|
inquiry.responded_at = timezone.now()
|
|
inquiry.responded_by = request.user
|
|
inquiry.status = "resolved"
|
|
inquiry.save()
|
|
|
|
return Response({"message": "Response submitted successfully"})
|
|
|
|
|
|
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 Location
|
|
|
|
locations = Location.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 MainSection, SubSection
|
|
|
|
# Get available sections that have subsections for this location
|
|
available_section_ids = (
|
|
SubSection.objects.filter(location_id=location_id).values_list("main_section_id", flat=True).distinct()
|
|
)
|
|
|
|
sections = MainSection.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 SubSection
|
|
|
|
subsections = SubSection.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_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 .models import ComplaintExplanation, ExplanationAttachment
|
|
from apps.notifications.services import NotificationService
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
|
|
# Get complaint
|
|
complaint = get_object_or_404(Complaint, id=complaint_id)
|
|
|
|
# Validate token with staff and department prefetch
|
|
# Also prefetch escalation relationship to show original staff explanation to manager
|
|
explanation = get_object_or_404(
|
|
ComplaintExplanation.objects.select_related("staff", "staff__department", "staff__report_to").prefetch_related(
|
|
"escalated_from_staff"
|
|
),
|
|
complaint=complaint,
|
|
token=token,
|
|
)
|
|
|
|
# Get original staff explanation if this is an escalation
|
|
original_explanation = None
|
|
if hasattr(explanation, "escalated_from_staff"):
|
|
# This explanation was created as a result of escalation
|
|
# Get the original staff explanation
|
|
original_explanation = (
|
|
ComplaintExplanation.objects.filter(escalated_to_manager=explanation).select_related("staff").first()
|
|
)
|
|
|
|
# Check if token is already used
|
|
if explanation.is_used:
|
|
return render(
|
|
request,
|
|
"complaints/explanation_already_submitted.html",
|
|
{"complaint": complaint, "explanation": explanation},
|
|
)
|
|
|
|
if request.method == "POST":
|
|
# Handle form submission
|
|
explanation_text = request.POST.get("explanation", "").strip()
|
|
|
|
if not explanation_text:
|
|
return render(
|
|
request,
|
|
"complaints/explanation_form.html",
|
|
{
|
|
"complaint": complaint,
|
|
"explanation": explanation,
|
|
"original_explanation": original_explanation,
|
|
"error": "Please provide your explanation.",
|
|
},
|
|
)
|
|
|
|
# Save explanation
|
|
explanation.explanation = explanation_text
|
|
explanation.is_used = True
|
|
explanation.responded_at = timezone.now()
|
|
explanation.save()
|
|
|
|
# Handle file 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,
|
|
)
|
|
|
|
# Notify complaint assignee
|
|
if complaint.assigned_to and complaint.assigned_to.email:
|
|
site = get_current_site(request)
|
|
complaint_url = f"https://{site.domain}/complaints/{complaint.id}/"
|
|
|
|
subject = f"New Explanation Received - Complaint #{complaint.id}"
|
|
|
|
email_body = f"""
|
|
Dear {complaint.assigned_to.get_full_name()},
|
|
|
|
A new explanation has been submitted for the following complaint:
|
|
|
|
COMPLAINT DETAILS:
|
|
----------------
|
|
Reference: #{complaint.id}
|
|
Title: {complaint.title}
|
|
Severity: {complaint.get_severity_display()}
|
|
|
|
EXPLANATION SUBMITTED BY:
|
|
------------------------
|
|
{explanation.staff}
|
|
|
|
EXPLANATION:
|
|
-----------
|
|
{explanation.explanation}
|
|
|
|
"""
|
|
if files:
|
|
email_body += f"""
|
|
ATTACHMENTS:
|
|
------------
|
|
{len(files)} file(s) attached
|
|
"""
|
|
|
|
email_body += f"""
|
|
|
|
To view the complaint and explanation, please visit:
|
|
{complaint_url}
|
|
|
|
---
|
|
This is an automated message from PX360 Complaint Management System.
|
|
"""
|
|
|
|
try:
|
|
NotificationService.send_email(
|
|
email=complaint.assigned_to.email,
|
|
subject=subject,
|
|
message=email_body,
|
|
related_object=complaint,
|
|
metadata={
|
|
"notification_type": "explanation_submitted",
|
|
"explanation_id": str(explanation.id),
|
|
"staff_id": str(explanation.staff.id) if explanation.staff else None,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
# Log error but don't fail the submission
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f"Failed to send notification email: {e}")
|
|
|
|
# Create complaint update
|
|
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,
|
|
},
|
|
)
|
|
|
|
# Redirect to success page
|
|
return render(
|
|
request,
|
|
"complaints/explanation_success.html",
|
|
{"complaint": complaint, "explanation": explanation, "attachment_count": len(files)},
|
|
)
|
|
|
|
# GET request - display form
|
|
return render(
|
|
request,
|
|
"complaints/explanation_form.html",
|
|
{"complaint": complaint, "explanation": explanation, "original_explanation": original_explanation},
|
|
)
|
|
|
|
|
|
from django.http import HttpResponse
|
|
|
|
|
|
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
|
|
|
|
# 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(),
|
|
},
|
|
)
|
|
|
|
# Generate PDF using WeasyPrint
|
|
try:
|
|
from weasyprint import HTML
|
|
|
|
pdf_file = HTML(string=html_string).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)
|