""" Complaints views and viewsets """ import logging from django.contrib.auth.decorators import login_required from django.conf import settings from django.db.models import Q from django.shortcuts import get_object_or_404, render, redirect from django.urls import reverse from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from apps.core.services import AuditService from .models import ( Complaint, ComplaintAttachment, ComplaintExplanation, ComplaintMeeting, ComplaintPRInteraction, ComplaintStatus, ComplaintUpdate, InvestigationStatus, Inquiry, ) from .serializers import ( ComplaintAttachmentSerializer, ComplaintListSerializer, ComplaintMeetingSerializer, ComplaintPRInteractionSerializer, ComplaintSerializer, ComplaintUpdateSerializer, InquirySerializer, ) from .services.complaint_service import ComplaintService, ComplaintServiceError logger = logging.getLogger(__name__) def map_complaint_category_to_action_category(complaint_category_code): """ Map complaint category code to PX Action category. Provides intelligent mapping from complaint categories to PX Action categories. Returns 'other' as fallback if no match found. """ if not complaint_category_code: return "other" mapping = { # Clinical issues "clinical": "clinical_quality", "medical": "clinical_quality", "diagnosis": "clinical_quality", "treatment": "clinical_quality", "medication": "clinical_quality", "care": "clinical_quality", # Safety issues "safety": "patient_safety", "risk": "patient_safety", "incident": "patient_safety", "infection": "patient_safety", "harm": "patient_safety", # Service quality "service": "service_quality", "communication": "service_quality", "wait": "service_quality", "response": "service_quality", "customer_service": "service_quality", "timeliness": "service_quality", "waiting_time": "service_quality", # Staff behavior "staff": "staff_behavior", "behavior": "staff_behavior", "attitude": "staff_behavior", "professionalism": "staff_behavior", "rude": "staff_behavior", "respect": "staff_behavior", # Facility "facility": "facility", "environment": "facility", "cleanliness": "facility", "equipment": "facility", "infrastructure": "facility", "parking": "facility", "accessibility": "facility", # Process "process": "process_improvement", "administrative": "process_improvement", "billing": "process_improvement", "procedure": "process_improvement", "workflow": "process_improvement", "registration": "process_improvement", "appointment": "process_improvement", } # Try exact match first category_lower = complaint_category_code.lower() if category_lower in mapping: return mapping[category_lower] # Try partial match (contains the keyword) for keyword, action_category in mapping.items(): if keyword in category_lower: return action_category # Fallback to 'other' return "other" class ComplaintViewSet(viewsets.ModelViewSet): """ ViewSet for Complaints with workflow actions. Permissions: - All authenticated users can view complaints - PX Admins and Hospital Admins can create/manage complaints """ queryset = Complaint.objects.all() permission_classes = [IsAuthenticated] filterset_fields = [ "status", "severity", "priority", "category", "source", "hospital", "department", "staff", "assigned_to", "is_overdue", "hospital__organization", ] search_fields = [ "title", "description", "reference_number", "patient__mrn", "patient__first_name", "patient__last_name", ] ordering_fields = ["created_at", "due_at", "severity"] ordering = ["-created_at"] def get_serializer_class(self): """Use simplified serializer for list view""" if self.action == "list": return ComplaintListSerializer return ComplaintSerializer def get_queryset(self): """Filter complaints based on user role""" queryset = ( super() .get_queryset() .select_related( "patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by", "created_by" ) .prefetch_related("attachments", "updates") ) user = self.request.user if user.is_px_admin(): if hasattr(self.request, "tenant_hospital") and self.request.tenant_hospital: return queryset.filter(hospital=self.request.tenant_hospital) return queryset # Source Users see ONLY complaints THEY created if hasattr(user, "source_user_profile") and user.source_user_profile.exists(): return queryset.filter(created_by=user) # Patients see ONLY their own complaints (if they have user accounts) # This assumes patients can have user accounts linked via patient.user if hasattr(user, "patient_profile"): return queryset.filter(patient__user=user) # Hospital Admins see complaints for their hospital if user.is_hospital_admin() and user.hospital: return queryset.filter(hospital=user.hospital) # Department Managers see complaints for their department if user.is_department_manager() and user.department: return queryset.filter(department=user.department) # Others see complaints for their hospital if user.hospital: return queryset.filter(hospital=user.hospital) return queryset.none() def get_object(self): """ Override get_object to allow PX Admins to access complaints for specific actions (request_explanation, resend_explanation, send_notification, assignable_admins). """ queryset = self.filter_queryset(self.get_queryset()) # PX Admins can access any complaint for specific actions if self.request.user.is_px_admin() and self.action in [ "request_explanation", "resend_explanation", "send_notification", "assignable_admins", "escalate_explanation", "review_explanation", ]: # Bypass queryset filtering and get directly by pk lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field lookup_value = self.kwargs[lookup_url_kwarg] return get_object_or_404(Complaint, pk=lookup_value) # Normal behavior for other users/actions lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} obj = get_object_or_404(queryset, **filter_kwargs) # May raise a permission denied self.check_object_permissions(self.request, obj) return obj def perform_create(self, serializer): """Log complaint creation and trigger AI analysis""" complaint = serializer.save(created_by=self.request.user) ComplaintService.post_create_hooks(complaint, self.request.user, request=self.request) @action(detail=True, methods=["post"]) def activate(self, request, pk=None): """Activate complaint by assigning it to current user.""" complaint = self.get_object() try: result = ComplaintService.activate(complaint, request.user, request=request) except ComplaintServiceError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response( { "message": "Complaint activated successfully", "assigned_to": { "id": str(request.user.id), "name": request.user.get_full_name(), "roles": request.user.get_role_names(), }, "assigned_at": complaint.assigned_at.isoformat(), "status": complaint.status, } ) @action(detail=True, methods=["post"]) def assign(self, request, pk=None): """Assign complaint to user (PX Admin or Hospital Admin)""" complaint = self.get_object() user_id = request.data.get("user_id") if not user_id: return Response({"error": "user_id is required"}, status=status.HTTP_400_BAD_REQUEST) from apps.accounts.models import User try: target_user = User.objects.get(id=user_id) ComplaintService.assign(complaint, target_user, request.user, request=request) except User.DoesNotExist: return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND) except ComplaintServiceError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({"message": "Complaint assigned successfully"}) @action(detail=True, methods=["get"]) def assignable_admins(self, request, pk=None): """ Get assignable admins (PX Admins and Hospital Admins) for this complaint. Returns list of all PX Admins and Hospital Admins. Supports searching by name. """ complaint = self.get_object() # Check if user has permission to assign admins if not request.user.is_px_admin(): return Response( {"error": "Only PX Admins can assign complaints to admins"}, status=status.HTTP_403_FORBIDDEN ) from apps.accounts.models import User # Get search parameter search = request.query_params.get("search", "").strip() # Simple query - get all PX Admins and Hospital Admins base_query = Q(groups__name="PX Admin") | Q(groups__name="Hospital Admin") queryset = ( User.objects.filter(base_query, is_active=True) .select_related("hospital") .prefetch_related("groups") .order_by("first_name", "last_name") ) # Search by name or email if provided if search: queryset = queryset.filter( Q(first_name__icontains=search) | Q(last_name__icontains=search) | Q(email__icontains=search) ) # Serialize admins_list = [] for user in queryset: roles = user.get_role_names() role_display = ", ".join(roles) admins_list.append( { "id": str(user.id), "name": user.get_full_name(), "email": user.email, "roles": roles, "role_display": role_display, "hospital": user.hospital.name if user.hospital else None, "is_px_admin": user.is_px_admin(), "is_hospital_admin": user.is_hospital_admin(), } ) return Response( { "complaint_id": str(complaint.id), "hospital_id": str(complaint.hospital.id), "hospital_name": complaint.hospital.name, "current_assignee": { "id": str(complaint.assigned_to.id), "name": complaint.assigned_to.get_full_name(), "email": complaint.assigned_to.email, "roles": complaint.assigned_to.get_role_names(), } if complaint.assigned_to else None, "admin_count": len(admins_list), "admins": admins_list, } ) @action(detail=True, methods=["post"]) def change_status(self, request, pk=None): """Change complaint status""" complaint = self.get_object() try: ComplaintService.change_status( complaint, request.data.get("status", ""), request.user, request=request, note=request.data.get("note", ""), resolution=request.data.get("resolution", ""), resolution_category=request.data.get("resolution_category", ""), ) except ComplaintServiceError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({"message": "Status updated successfully"}) @action(detail=True, methods=["post"]) def add_note(self, request, pk=None): """Add note to complaint""" complaint = self.get_object() note = request.data.get("note") try: update = ComplaintService.add_note(complaint, note, request.user, request=request) except ComplaintServiceError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) serializer = ComplaintUpdateSerializer(update) return Response(serializer.data, status=status.HTTP_201_CREATED) @action(detail=True, methods=["get"]) def staff_suggestions(self, request, pk=None): """ Get staff matching suggestions for a complaint. Returns potential staff matches from AI analysis, allowing PX Admins to review and select correct staff. """ complaint = self.get_object() # Check if user is PX Admin if not request.user.is_px_admin(): return Response({"error": "Only PX Admins can access staff suggestions"}, status=status.HTTP_403_FORBIDDEN) # Get AI analysis metadata ai_analysis = complaint.metadata.get("ai_analysis", {}) staff_matches = ai_analysis.get("staff_matches", []) extracted_name = ai_analysis.get("extracted_staff_name", "") needs_review = ai_analysis.get("needs_staff_review", False) matched_staff_id = ai_analysis.get("matched_staff_id") return Response( { "extracted_name": extracted_name, "staff_matches": staff_matches, "current_staff_id": matched_staff_id, "needs_staff_review": needs_staff_review, "staff_match_count": len(staff_matches), } ) @action(detail=True, methods=["get"]) def hospital_staff(self, request, pk=None): """ Get all staff from complaint's hospital for manual selection. Allows PX Admins to manually select staff. Supports filtering by department. """ complaint = self.get_object() # Check if user is PX Admin if not request.user.is_px_admin(): return Response( {"error": "Only PX Admins can access hospital staff list"}, status=status.HTTP_403_FORBIDDEN ) from apps.organizations.models import Staff # Get query params department_id = request.query_params.get("department_id") search = request.query_params.get("search", "").strip() # Build query queryset = Staff.objects.filter(hospital=complaint.hospital, status="active").select_related("department") # Filter by department if specified if department_id: queryset = queryset.filter(department_id=department_id) # Search by name if provided if search: queryset = queryset.filter( Q(first_name__icontains=search) | Q(last_name__icontains=search) | Q(first_name_ar__icontains=search) | Q(last_name_ar__icontains=search) | Q(job_title__icontains=search) ) # Order by department and name queryset = queryset.order_by("department__name", "first_name", "last_name") # Serialize staff_list = [] for staff in queryset: staff_list.append( { "id": str(staff.id), "name_en": f"{staff.first_name} {staff.last_name}", "name_ar": f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "", "job_title": staff.job_title, "specialization": staff.specialization, "department": staff.department.name if staff.department else None, "department_id": str(staff.department.id) if staff.department else None, } ) return Response( { "hospital_id": str(complaint.hospital.id), "hospital_name": complaint.hospital.name, "staff_count": len(staff_list), "staff": staff_list, } ) @action(detail=True, methods=["post"]) def assign_staff(self, request, pk=None): """ Manually assign staff to a complaint. Allows PX Admins to assign specific staff member, especially when AI matching is ambiguous. """ complaint = self.get_object() # Check if complaint is in active status if not complaint.is_active_status: return Response( { "error": f"Cannot assign staff to complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved." }, status=status.HTTP_400_BAD_REQUEST, ) # Check if user is PX Admin if not request.user.is_px_admin(): return Response( {"error": "Only PX Admins can assign staff to complaints"}, status=status.HTTP_403_FORBIDDEN ) staff_id = request.data.get("staff_id") reason = request.data.get("reason", "") if not staff_id: return Response({"error": "staff_id is required"}, status=status.HTTP_400_BAD_REQUEST) from apps.organizations.models import Staff try: staff = Staff.objects.get(id=staff_id) except Staff.DoesNotExist: return Response({"error": "Staff not found"}, status=status.HTTP_404_NOT_FOUND) # Check staff belongs to same hospital if staff.hospital != complaint.hospital: return Response( {"error": "Staff does not belong to complaint hospital"}, status=status.HTTP_400_BAD_REQUEST ) # Update complaint old_staff_id = str(complaint.staff.id) if complaint.staff else None complaint.staff = staff # Auto-set department from staff complaint.department = staff.department complaint.save(update_fields=["staff", "department"]) # Update metadata to clear review flag if not complaint.metadata: complaint.metadata = {} if "ai_analysis" in complaint.metadata: complaint.metadata["ai_analysis"]["needs_staff_review"] = False complaint.metadata["ai_analysis"]["staff_manually_assigned"] = True complaint.metadata["ai_analysis"]["staff_assigned_by"] = str(request.user.id) complaint.metadata["ai_analysis"]["staff_assigned_at"] = timezone.now().isoformat() complaint.metadata["ai_analysis"]["staff_assignment_reason"] = reason complaint.save(update_fields=["metadata"]) # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title}). {reason}" if reason else f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title})", created_by=request.user, metadata={"old_staff_id": old_staff_id, "new_staff_id": str(staff.id), "manual_assignment": True}, ) # Log audit AuditService.log_from_request( event_type="staff_assigned", description=f"Staff {staff.first_name} {staff.last_name} manually assigned to complaint by {request.user.get_full_name()}", request=request, content_object=complaint, metadata={"old_staff_id": old_staff_id, "new_staff_id": str(staff.id), "reason": reason}, ) return Response( { "message": "Staff assigned successfully", "staff_id": str(staff.id), "staff_name": f"{staff.first_name} {staff.last_name}", } ) @action(detail=True, methods=["post"]) def change_department(self, request, pk=None): """Change complaint department""" complaint = self.get_object() department_id = request.data.get("department_id") if not department_id: return Response({"error": "department_id is required"}, status=status.HTTP_400_BAD_REQUEST) from apps.organizations.models import Department try: department = Department.objects.get(id=department_id) ComplaintService.change_department(complaint, department, request.user, request=request) except Department.DoesNotExist: return Response({"error": "Department not found"}, status=status.HTTP_404_NOT_FOUND) except ComplaintServiceError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response( { "message": "Department changed successfully", "department_id": str(department.id), "department_name": department.name, } ) @action(detail=True, methods=["post"]) def create_action_from_ai(self, request, pk=None): """Create PX Action using AI service to generate action details from complaint""" complaint = self.get_object() # Use AI service to generate action data from apps.core.ai_service import AIService try: action_data = AIService.create_px_action_from_complaint(complaint) except Exception as e: return Response( {"error": f"Failed to generate action data: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # Get optional assigned_to from request (AI doesn't assign by default) assigned_to_id = request.data.get("assigned_to") assigned_to = None if assigned_to_id: from apps.accounts.models import User try: assigned_to = User.objects.get(id=assigned_to_id) except User.DoesNotExist: return Response({"error": "Assigned user not found"}, status=status.HTTP_404_NOT_FOUND) # Create PX Action from apps.px_action_center.models import PXAction, PXActionLog from django.contrib.contenttypes.models import ContentType complaint_content_type = ContentType.objects.get_for_model(Complaint) action = PXAction.objects.create( source_type="complaint", content_type=complaint_content_type, object_id=complaint.id, title=action_data["title"], description=action_data["description"], hospital=complaint.hospital, department=complaint.department, category=action_data["category"], priority=action_data["priority"], severity=action_data["severity"], assigned_to=assigned_to, status="open", metadata={ "source_complaint_id": str(complaint.id), "source_complaint_title": complaint.title, "ai_generated": True, "ai_reasoning": action_data.get("reasoning", ""), "created_from_ai_suggestion": True, }, ) # Create action log entry PXActionLog.objects.create( action=action, log_type="note", message=f"Action generated by AI for complaint: {complaint.title}", created_by=request.user, metadata={ "complaint_id": str(complaint.id), "ai_generated": True, "category": action_data["category"], "priority": action_data["priority"], "severity": action_data["severity"], }, ) # Create complaint update ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"PX Action created from AI-generated suggestion (Action #{action.id}) - {action_data['category']}", created_by=request.user, metadata={"action_id": str(action.id), "category": action_data["category"]}, ) # Log audit AuditService.log_from_request( event_type="action_created_from_ai", description=f"PX Action created from AI analysis for complaint: {complaint.title}", request=request, content_object=action, metadata={ "complaint_id": str(complaint.id), "category": action_data["category"], "priority": action_data["priority"], "severity": action_data["severity"], "ai_reasoning": action_data.get("reasoning", ""), }, ) return Response( { "action_id": str(action.id), "message": "Action created successfully from AI analysis", "action_data": { "title": action_data["title"], "category": action_data["category"], "priority": action_data["priority"], "severity": action_data["severity"], }, }, status=status.HTTP_201_CREATED, ) @action(detail=True, methods=["post"]) def send_notification(self, request, pk=None): """ Send email notification to staff member or department head. Sends complaint notification with AI-generated summary (editable by user). Logs the operation to NotificationLog and ComplaintUpdate. Recipient Priority: 1. Staff with user account 2. Staff with email field 3. Department manager """ complaint = self.get_object() # Get email message (required) email_message = request.data.get("email_message", "").strip() if not email_message: return Response({"error": "email_message is required"}, status=status.HTTP_400_BAD_REQUEST) # Get additional message (optional) additional_message = request.data.get("additional_message", "").strip() # Determine recipient with priority logic recipient = None recipient_display = None recipient_type = None recipient_email = None # Priority 1: Staff member with user account if complaint.staff and complaint.staff.user: recipient = complaint.staff.user recipient_display = str(complaint.staff) recipient_type = "Staff Member (User Account)" recipient_email = recipient.email # Priority 2: Staff member with email field (no user account) elif complaint.staff and complaint.staff.email: recipient_display = str(complaint.staff) recipient_type = "Staff Member (Email)" recipient_email = complaint.staff.email # Priority 3: Department head elif complaint.department and complaint.department.manager: recipient = complaint.department.manager recipient_display = recipient.get_full_name() recipient_type = "Department Head" recipient_email = recipient.email # Check if we found a recipient with email if not recipient_email: return Response( { "error": "No valid recipient found. Complaint must have staff with email, or a department manager with email." }, status=status.HTTP_400_BAD_REQUEST, ) # Construct email content subject = f"Complaint Notification - #{complaint.id}" # Build email body email_body = f""" Dear {recipient_display}, You have been assigned to review the following complaint: COMPLAINT DETAILS: ---------------- ID: #{complaint.id} Title: {complaint.title} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} Status: {complaint.get_status_display()} SUMMARY: -------- {email_message} """ # Add patient info if available if complaint.patient: email_body += f""" PATIENT INFORMATION: ------------------ Name: {complaint.patient.get_full_name()} MRN: {complaint.patient.mrn} """ # Add additional message if provided if additional_message: email_body += f""" ADDITIONAL MESSAGE: ------------------ {additional_message} """ # Add link to complaint from django.contrib.sites.shortcuts import get_current_site site = get_current_site(request) complaint_url = f"https://{site.domain}/complaints/{complaint.id}/" email_body += f""" To view the full complaint details, please visit: {complaint_url} Thank you for your attention to this matter. --- This is an automated message from PX360 Complaint Management System. """ # Send email using NotificationService from apps.notifications.services import NotificationService, get_email_header_html try: notification_log = NotificationService.send_email( email=recipient_email, subject=subject, message=email_body, html_message=f"""
{get_email_header_html()}

{subject}

Dear {recipient_display},

You have been assigned to review the following complaint:

Reference:#{complaint.id}
Title:{complaint.title}
Severity:{complaint.get_severity_display()}
Priority:{complaint.get_priority_display()}
View Complaint
""", related_object=complaint, metadata={ "notification_type": "complaint_notification", "recipient_type": recipient_type, "recipient_id": str(recipient.id) if recipient else None, "sender_id": str(request.user.id), "has_additional_message": bool(additional_message), }, ) except Exception as e: return Response({"error": f"Failed to send email: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # Create ComplaintUpdate entry ComplaintUpdate.objects.create( complaint=complaint, update_type="communication", message=f"Email notification sent to {recipient_type}: {recipient_display}", created_by=request.user, metadata={ "recipient_type": recipient_type, "recipient_id": str(recipient.id) if recipient else None, "notification_log_id": str(notification_log.id) if notification_log else None, }, ) # Log audit AuditService.log_from_request( event_type="notification_sent", description=f"Email notification sent to {recipient_type}: {recipient_display}", request=request, content_object=complaint, metadata={ "recipient_type": recipient_type, "recipient_id": str(recipient.id) if recipient else None, "recipient_email": recipient_email, }, ) return Response( { "success": True, "message": "Email notification sent successfully", "recipient": recipient_display, "recipient_type": recipient_type, "recipient_email": recipient_email, } ) @action(detail=True, methods=["post"]) def send_to_department(self, request, pk=None): """ Send complaint to department for response. Delegates to ComplaintService for shared logic. Routes through department champion. """ complaint = self.get_object() if not complaint.is_active_status: return Response( {"error": f"Cannot send complaint to department with status '{complaint.get_status_display()}'"}, status=status.HTTP_400_BAD_REQUEST, ) involved_staff = complaint.involved_staff.select_related( "staff", "staff__department", "staff__department__champion", "staff__department__manager" ).all() if not involved_staff.exists(): return Response( {"error": "No involved staff found for this complaint"}, status=status.HTTP_400_BAD_REQUEST, ) department_groups = {} for staff_inv in involved_staff: staff = staff_inv.staff dept = staff.department if dept and dept.champion: dept_key = str(dept.id) if dept_key not in department_groups: champion = dept.champion champion_email = champion.email or (champion.user.email if hasattr(champion, 'user') and champion.user else None) dept_manager = dept.manager department_groups[dept_key] = { "department_id": dept_key, "department_name": dept.get_localized_name(), "champion": champion, "champion_id": str(champion.id), "champion_name": champion.get_full_name(), "champion_email": champion_email, "dept_manager": dept_manager, "dept_manager_id": str(dept_manager.id) if dept_manager else None, "dept_manager_name": dept_manager.get_full_name() if dept_manager else None, "dept_manager_email": dept_manager.email if dept_manager else None, "staff_list": [], } department_groups[dept_key]["staff_list"].append({ "staff_name": staff.get_full_name(), "role": staff_inv.get_role_display(), }) if not department_groups: return Response( {"error": "No departments with assigned champions found for involved staff"}, status=status.HTTP_400_BAD_REQUEST, ) request_message = request.data.get("request_message", "").strip() from django.contrib.sites.shortcuts import get_current_site site = get_current_site(request) domain = site.domain selected_dept_ids = list(department_groups.keys()) try: result = ComplaintService.send_to_department( complaint, department_groups, selected_dept_ids, request_message, request.user, domain, request=request, ) except ComplaintServiceError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response( { "success": result["champion_count"] > 0, "message": "Explanation requests sent to department champions", "results": result["results"], "champion_count": result["champion_count"], "manager_notified": result["manager_count"] > 0, } ) @action(detail=True, methods=["post"]) def resend_explanation(self, request, pk=None): """ Resend explanation request email to staff member only. Regenerates the token with a new value and resends the email to the staff member. Manager is not resent the informational email - they already received it initially. Only allows resending if explanation has not been submitted yet. """ complaint = self.get_object() # Check if complaint is in active status if not complaint.is_active_status: return Response( { "error": f"Cannot resend explanation for complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved." }, status=status.HTTP_400_BAD_REQUEST, ) # Check if complaint has staff assigned if not complaint.staff: return Response({"error": "No staff assigned to this complaint"}, status=status.HTTP_400_BAD_REQUEST) # Check if explanation exists for this staff from .models import ComplaintExplanation try: explanation = ComplaintExplanation.objects.filter(complaint=complaint, staff=complaint.staff).latest( "created_at" ) except ComplaintExplanation.DoesNotExist: return Response( {"error": "No explanation found for this complaint and staff"}, status=status.HTTP_404_NOT_FOUND ) # Check if already submitted (can only resend if not submitted) if explanation.is_used: return Response( {"error": "Explanation already submitted, cannot resend. Create a new explanation request."}, status=status.HTTP_400_BAD_REQUEST, ) # Generate new token import secrets new_token = secrets.token_urlsafe(32) explanation.token = new_token explanation.email_sent_at = timezone.now() explanation.save() # Determine recipient email if complaint.staff.user and complaint.staff.user.email: recipient_email = complaint.staff.user.email recipient_display = str(complaint.staff) elif complaint.staff.email: recipient_email = complaint.staff.email recipient_display = str(complaint.staff) else: return Response({"error": "Staff member has no email address"}, status=status.HTTP_400_BAD_REQUEST) # Send email with new link from django.contrib.sites.shortcuts import get_current_site from apps.notifications.services import NotificationService, get_email_header_html site = get_current_site(request) explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{new_token}/" # Build email subject subject = f"Explanation Request (Resent) - Complaint #{complaint.id}" # Build email body email_body = f""" Dear {recipient_display}, We have resent the explanation request for the following complaint: COMPLAINT DETAILS: ---------------- Reference: #{complaint.id} Title: {complaint.title} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} Status: {complaint.get_status_display()} {complaint.description} """ # Add patient info if available if complaint.patient: email_body += f""" PATIENT INFORMATION: ------------------ Name: {complaint.patient.get_full_name()} MRN: {complaint.patient.mrn} """ email_body += f""" SUBMIT YOUR EXPLANATION: ------------------------ Your perspective is important. Please submit your explanation about this complaint: {explanation_link} Note: This link can only be used once. After submission, it will expire. If you have any questions, please contact PX team. --- This is an automated message from PX360 Complaint Management System. """ # Send email try: notification_log = NotificationService.send_email( email=recipient_email, subject=subject, message=email_body, html_message=f"""
{get_email_header_html()}

Explanation Request (Resent)

Dear {recipient_display},

We have resent the explanation request for complaint #{complaint.id}:

Title:{complaint.title}
Severity:{complaint.get_severity_display()}
Priority:{complaint.get_priority_display()}

This link can only be used once. After submission, it will expire.

Submit Explanation
""", related_object=complaint, metadata={ "notification_type": "explanation_request_resent", "recipient_type": "staff", "staff_id": str(complaint.staff.id), "explanation_id": str(explanation.id), "requested_by_id": str(request.user.id), "resent": True, }, ) except Exception as e: return Response({"error": f"Failed to send email: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # Create ComplaintUpdate entry ComplaintUpdate.objects.create( complaint=complaint, update_type="communication", message=f"Explanation request resent to {recipient_display}", created_by=request.user, metadata={ "explanation_id": str(explanation.id), "staff_id": str(complaint.staff.id), "notification_log_id": str(notification_log.id) if notification_log else None, "resent": True, }, ) # Log audit AuditService.log_from_request( event_type="explanation_resent", description=f"Explanation request resent to {recipient_display}", request=request, content_object=complaint, metadata={"explanation_id": str(explanation.id), "staff_id": str(complaint.staff.id)}, ) return Response( { "success": True, "message": "Explanation request resent successfully to staff member", "explanation_id": str(explanation.id), "recipient": recipient_display, "new_token": new_token, "explanation_link": explanation_link, }, status=status.HTTP_200_OK, ) @action(detail=True, methods=["post"]) def send_explanation_reminder(self, request, pk=None): """ Manually send first or second reminder for explanation request. Allows admin to trigger a reminder email when the automated task didn't run. """ complaint = self.get_object() if not complaint.is_active_status: return Response( {"error": f"Cannot send reminder for complaint with status '{complaint.get_status_display()}'."}, status=status.HTTP_400_BAD_REQUEST, ) explanation_id = request.data.get("explanation_id") reminder_type = request.data.get("reminder_type", "first") if not explanation_id: return Response({"error": "explanation_id is required"}, status=status.HTTP_400_BAD_REQUEST) try: explanation = ComplaintExplanation.objects.get(pk=explanation_id, complaint=complaint) except ComplaintExplanation.DoesNotExist: return Response({"error": "Explanation not found"}, status=status.HTTP_404_NOT_FOUND) if explanation.is_used: return Response({"error": "Explanation already submitted"}, status=status.HTTP_400_BAD_REQUEST) if reminder_type not in ("first", "second"): return Response({"error": "reminder_type must be 'first' or 'second'"}, status=status.HTTP_400_BAD_REQUEST) if reminder_type == "first": if explanation.reminder_sent_at: return Response({"error": "First reminder already sent"}, status=status.HTTP_400_BAD_REQUEST) else: if not explanation.reminder_sent_at: return Response( {"error": "First reminder must be sent before second reminder"}, status=status.HTTP_400_BAD_REQUEST, ) if explanation.second_reminder_sent_at: return Response({"error": "Second reminder already sent"}, status=status.HTTP_400_BAD_REQUEST) if not explanation.staff.email: return Response({"error": "Staff member has no email address"}, status=status.HTTP_400_BAD_REQUEST) now = timezone.now() hours_remaining = 0 if explanation.sla_due_at: hours_remaining = max(0, int((explanation.sla_due_at - now).total_seconds() / 3600)) context = { "explanation": explanation, "complaint": complaint, "staff": explanation.staff, "hours_remaining": hours_remaining, "due_date": explanation.sla_due_at, "site_url": request.build_absolute_uri("/").rstrip("/"), } if reminder_type == "first": subject = f"Reminder: Explanation Request - Complaint #{str(complaint.id)[:8]}" try: from django.template.loader import render_to_string from django.core.mail import send_mail from django.conf import settings message_en = render_to_string("complaints/emails/explanation_reminder_en.txt", context) message_ar = render_to_string("complaints/emails/explanation_reminder_ar.txt", context) html_message = render_to_string("emails/explanation_reminder.html", context) send_mail( subject=subject, message=f"{message_en}\n\n{message_ar}", from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[explanation.staff.email], fail_silently=False, html_message=html_message, ) explanation.reminder_sent_at = now explanation.save(update_fields=["reminder_sent_at"]) except Exception as e: return Response( {"error": f"Failed to send reminder: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) else: subject = f"URGENT - Final Reminder: Explanation Request - Complaint #{str(complaint.id)[:8]}" try: from django.template.loader import render_to_string from django.core.mail import send_mail from django.conf import settings message_en = render_to_string("complaints/emails/explanation_second_reminder_en.txt", context) message_ar = render_to_string("complaints/emails/explanation_second_reminder_ar.txt", context) html_message = render_to_string("emails/explanation_second_reminder.html", context) send_mail( subject=subject, message=f"{message_en}\n\n{message_ar}", from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[explanation.staff.email], fail_silently=False, html_message=html_message, ) explanation.second_reminder_sent_at = now explanation.save(update_fields=["second_reminder_sent_at"]) except Exception as e: return Response( {"error": f"Failed to send reminder: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) label = "first" if reminder_type == "first" else "second" recipient_display = str(explanation.staff) ComplaintUpdate.objects.create( complaint=complaint, update_type="communication", message=f"{label.capitalize()} explanation reminder sent to {recipient_display}", created_by=request.user, metadata={ "explanation_id": str(explanation.id), "staff_id": str(explanation.staff.id), "reminder_type": reminder_type, }, ) AuditService.log_from_request( event_type=f"explanation_{label}_reminder_sent", description=f"{label.capitalize()} explanation reminder sent to {recipient_display}", request=request, content_object=complaint, metadata={"explanation_id": str(explanation.id), "staff_id": str(explanation.staff.id)}, ) return Response( { "success": True, "message": f"{label.capitalize()} reminder sent to {recipient_display}", "explanation_id": str(explanation.id), "reminder_type": reminder_type, }, status=status.HTTP_200_OK, ) @action(detail=True, methods=["post"]) def generate_ai_resolution(self, request, pk=None): """ Generate AI-powered resolution note based on complaint details and explanations. Analyzes the complaint description, staff explanations, and manager explanations to generate a comprehensive resolution note for admin review. """ complaint = self.get_object() # Check permission - same logic as can_manage_complaint user = request.user can_generate = ( user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital) or (user.is_department_manager() and user.department == complaint.department) or complaint.assigned_to == user ) if not can_generate: return Response( {"error": "You do not have permission to generate AI resolution for this complaint"}, status=status.HTTP_403_FORBIDDEN, ) # Get all used explanations explanations = complaint.explanations.filter(is_used=True).select_related("staff") if not explanations.exists(): return Response( {"success": False, "error": "No explanations available to analyze. Please request explanations first."}, status=status.HTTP_400_BAD_REQUEST, ) # Build context for AI context = { "complaint": { "title": complaint.title, "description": complaint.description, "severity": complaint.get_severity_display(), "priority": complaint.get_priority_display(), "patient_name": complaint.patient.get_full_name() if complaint.patient else "Unknown", "department": complaint.department.name if complaint.department else "Unknown", }, "explanations": [], } for exp in explanations: exp_data = { "staff_name": exp.staff.get_full_name() if exp.staff else "Unknown", "employee_id": exp.staff.employee_id if exp.staff else "N/A", "department": exp.staff.department.name if exp.staff and exp.staff.department else "N/A", "explanation": exp.explanation, "submitted_at": exp.responded_at.strftime("%Y-%m-%d %H:%M") if exp.responded_at else "Unknown", } context["explanations"].append(exp_data) # Call AI service to generate resolution try: from apps.core.ai_service import AIService # Build prompt explanations_text = "" for i, exp in enumerate(context["explanations"], 1): explanations_text += f""" Explanation {i}: - Staff: {exp["staff_name"]} (ID: {exp["employee_id"]}, Dept: {exp["department"]}) - Submitted: {exp["submitted_at"]} - Content: {exp["explanation"]} """ prompt = f"""As a healthcare complaint resolution expert, analyze the following complaint and staff explanations to generate a comprehensive resolution note in BOTH English and Arabic. COMPLAINT DETAILS: - Title: {context["complaint"]["title"]} - Description: {context["complaint"]["description"]} - Severity: {context["complaint"]["severity"]} - Priority: {context["complaint"]["priority"]} - Patient: {context["complaint"]["patient_name"]} - Department: {context["complaint"]["department"]} STAFF EXPLANATIONS: {explanations_text} Based on the above information, generate a professional resolution note that: 1. Summarizes the main issue and root cause 2. References the key points from staff explanations 3. States the outcome/decision 4. Includes any corrective actions taken or planned 5. Addresses patient concerns 6. Mentions any follow-up actions The resolution should be written in a professional, empathetic tone suitable for healthcare settings. IMPORTANT: Provide the resolution in BOTH languages as JSON: {{ "resolution_en": "The resolution text in English (3-5 paragraphs)", "resolution_ar": "نص القرار بالعربية (3-5 فقرات)" }} Ensure both versions convey the same meaning and are professionally written.""" system_prompt = """You are an expert healthcare complaint resolution specialist fluent in both English and Arabic. Your task is to analyze complaints and staff explanations to generate comprehensive, professional resolution notes in both languages. Be objective, empathetic, and thorough. Focus on facts while acknowledging the patient's concerns. Write in a professional tone appropriate for medical records in both languages. Always provide valid JSON output with both resolution_en and resolution_ar fields.""" ai_response = AIService.chat_completion( prompt=prompt, system_prompt=system_prompt, temperature=0.4, max_tokens=1500, response_format="json_object", ) # Parse the JSON response import json import re try: resolution_data = json.loads(ai_response) except json.JSONDecodeError: # AI returned malformed JSON — try to extract fields via regex en_match = re.search(r'"resolution_en"\s*:\s*"((?:[^"\\]|\\.)*)"', ai_response, re.DOTALL) ar_match = re.search(r'"resolution_ar"\s*:\s*"((?:[^"\\]|\\.)*)"', ai_response, re.DOTALL) resolution_data = { "resolution_en": en_match.group(1).replace("\\n", "\n").replace('\\"', '"') if en_match else ai_response, "resolution_ar": ar_match.group(1).replace("\\n", "\n").replace('\\"', '"') if ar_match else "", } resolution_en = resolution_data.get("resolution_en", "").strip() resolution_ar = resolution_data.get("resolution_ar", "").strip() # Log the AI generation AuditService.log_from_request( event_type="ai_resolution_generated", description=f"AI resolution generated for complaint {complaint.reference_number}", request=request, content_object=complaint, metadata={ "complaint_id": str(complaint.id), "explanation_count": explanations.count(), "generated_resolution_en_length": len(resolution_en), "generated_resolution_ar_length": len(resolution_ar), }, ) return Response( { "success": True, "resolution_en": resolution_en, "resolution_ar": resolution_ar, "explanation_count": explanations.count(), } ) except Exception as e: logger.error(f"AI resolution generation failed: {e}") return Response( {"success": False, "error": f"Failed to generate resolution: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @action(detail=True, methods=["get"]) def generate_resolution_suggestion(self, request, pk=None): """ Generate AI resolution suggestion based on complaint and acceptable explanation. Uses the staff explanation if acceptable, otherwise uses manager explanation. Returns a suggested resolution text that can be edited or used directly. """ complaint = self.get_object() # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( {"error": "Only PX Admins or Hospital Admins can generate resolution suggestions"}, status=status.HTTP_403_FORBIDDEN, ) # Find acceptable explanation acceptable_explanation = None explanation_source = None # First, try to find a submitted staff explanation staff_explanation = complaint.explanations.filter( staff=complaint.staff, is_used=True ).first() if staff_explanation: acceptable_explanation = staff_explanation explanation_source = "staff" else: # Try to find a submitted manager explanation (escalated) manager_explanation = complaint.explanations.filter( is_used=True, metadata__is_escalation=True, ).first() if manager_explanation: acceptable_explanation = manager_explanation explanation_source = "manager" if not acceptable_explanation: return Response( { "error": "No submitted explanation found. Please request an explanation from the involved staff first.", "suggestion": None, }, status=status.HTTP_400_BAD_REQUEST, ) # Generate resolution using AI try: resolution_text = self._generate_ai_resolution( complaint=complaint, explanation=acceptable_explanation, source=explanation_source ) return Response( { "success": True, "suggestion": resolution_text, "source": explanation_source, "source_staff": acceptable_explanation.staff.get_full_name() if acceptable_explanation.staff else None, "explanation_id": str(acceptable_explanation.id), } ) except Exception as e: logger.error(f"Failed to generate resolution: {e}") return Response( {"error": "Failed to generate resolution suggestion", "detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) def _generate_ai_resolution(self, complaint, explanation, source): explanation_text = explanation.explanation explanation_by = explanation.staff.get_full_name() if explanation.staff else "Unknown" try: from apps.core.ai_service import AIService prompt = f"""Based on the following complaint and investigation, generate a professional resolution summary that will be sent to the patient. The response should be empathetic, clear, and describe the actions taken. Complaint: {complaint.description[:1000]} Severity: {complaint.get_severity_display()} Category: {complaint.category.get_localized_name() if complaint.category else 'N/A'} Explanation by {explanation_by} ({source}): {explanation_text[:1000]} Generate a JSON response with: - "resolution_en": Professional resolution summary in English (2-3 paragraphs) - "resolution_ar": Same resolution in Arabic""" result = AIService.chat_completion( prompt=prompt, response_format="json_object", ) import json parsed = json.loads(result) return parsed.get("resolution_en", result) except Exception as e: logger.warning(f"AI resolution generation failed, using template: {e}") return f"""Based on the complaint filed regarding: {complaint.title} After reviewing the complaint and the explanation provided by {explanation_by} ({source}), the following has been determined: {explanation_text} The matter has been addressed through appropriate channels. Appropriate measures have been implemented to address the concern and steps have been taken to prevent recurrence. The complaint is considered resolved.""" @action(detail=True, methods=["get"]) def ai_helper_suggestions(self, request, pk=None): complaint = self.get_object() if not (request.user.is_px_admin or request.user.is_hospital_admin()): return Response({"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN) try: from apps.core.ai_service import AIService from django.utils import timezone import json explanations_text = "" for exp in complaint.explanations.filter(is_used=True): name = exp.staff.get_full_name() if exp.staff else "Staff" explanations_text += f"\n- {name}: {exp.explanation[:500]}" prompt = f"""You are a patient experience resolution advisor. Based on this complaint, provide actionable suggestions for responding to the patient. Complaint: {complaint.description[:1500]} Severity: {complaint.get_severity_display()} Category: {complaint.category.get_localized_name() if complaint.category else 'N/A'} Priority: {complaint.get_priority_display()} Status: {complaint.get_status_display()} Staff explanations received: {explanations_text or 'None yet'} IMPORTANT: ALL text fields must be provided in BOTH English and Arabic. Generate a JSON response with this exact structure: {{ "suggestions": [ {{"title_en": "Short title", "title_ar": "عنوان قصير", "description_en": "Description", "description_ar": "الوصف"}} ], "recommended_actions_en": ["Action 1", "Action 2"], "recommended_actions_ar": ["الإجراء 1", "الإجراء 2"], "communication_tip_en": "Tip text", "communication_tip_ar": "نصيحة" }} Provide 3-5 suggestions. Keep them concise and focused on how to respond to the patient.""" result = AIService.chat_completion( prompt=prompt, response_format="json_object", max_tokens=1500, ) parsed = json.loads(result) # Persist to complaint complaint.ai_response_suggestions = parsed complaint.ai_response_suggestions_at = timezone.now() complaint.save(update_fields=["ai_response_suggestions", "ai_response_suggestions_at", "updated_at"]) return Response({"success": True}) except Exception as e: logger.error(f"AI helper suggestion failed: {e}") return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) def _get_pdf_context_data(self, complaint): """Compute all PDF template context fields from DB data.""" from apps.complaints.models import ComplaintInvolvedDepartment, ComplaintInvolvedStaff def fmt_date_ar(dt): if not dt: return "—" return dt.strftime("%Y/%m/%d %p %I:%M").replace("AM", "ص").replace("PM", "م") hospital_name = ( complaint.hospital.get_display_name_ar() if complaint.hospital and complaint.hospital.get_display_name_ar() else complaint.hospital.get_display_name() if complaint.hospital else "" ) dept_name = ( complaint.department.name_ar if complaint.department and complaint.department.name_ar else complaint.department.name_en or complaint.department.name if complaint.department else "" ) complainant_name = ( complaint.contact_name or complaint.patient_name or (complaint.patient.get_full_name() if complaint.patient else "") ) source_name = complaint.source.name_ar if complaint.source else "" accused = ComplaintInvolvedStaff.objects.filter( complaint=complaint, role="accused" ).select_related("staff", "staff__department").first() accused_staff_name = "" accused_staff_title = "" if accused and accused.staff: s = accused.staff accused_staff_name = s.name_ar or s.name or f"{s.first_name} {s.last_name}" accused_staff_title = s.job_title or "" sent_to_dept_at = complaint.sent_to_department_at or complaint.forwarded_to_dept_at first_response_dept = ComplaintInvolvedDepartment.objects.filter( complaint=complaint, response_submitted=True ).order_by("response_submitted_at").first() response_submitted_at = first_response_dept.response_submitted_at if first_response_dept else None return { "hospital_name": hospital_name, "department_name": dept_name, "complainant_name": complainant_name, "source_name": source_name, "accused_staff_name": accused_staff_name, "accused_staff_title": accused_staff_title, "submission_date": fmt_date_ar(complaint.created_at), "incident_date": complaint.incident_date.strftime("%Y/%m/%d") if complaint.incident_date else "—", "sent_to_dept_date": fmt_date_ar(sent_to_dept_at), "response_date": fmt_date_ar(response_submitted_at), } @action(detail=True, methods=["get", "post"]) def summary_preview(self, request, pk=None): """Get existing summary (GET) or generate AI preview (POST).""" complaint = self.get_object() user = request.user can_generate = ( user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital) or (user.is_department_manager() and user.department == complaint.department) or (user.is_px_employee() and complaint.hospital == user.hospital) ) if not can_generate: return Response( {"error": "You do not have permission to generate a PDF summary"}, status=status.HTTP_403_FORBIDDEN, ) from .models import ComplaintPdfSummary if request.method == "GET": ctx = self._get_pdf_context_data(complaint) summary = ComplaintPdfSummary.objects.filter(complaint=complaint).first() if summary: data = { "exists": True, "content_summary": summary.content_summary, "dept_response_summary": summary.dept_response_summary, **ctx, } if summary.file: data["has_file"] = True data["file_url"] = summary.file.url else: data["has_file"] = False return Response(data) return Response({"exists": False, **ctx}) try: import json from apps.complaints.models import ComplaintInvolvedDepartment dept_response_parts = [] involved_depts = ComplaintInvolvedDepartment.objects.filter( complaint=complaint ).select_related("department") for dept in involved_depts: resp = dept.response_notes_ar or dept.response_notes_en or dept.response_notes or "" resp = resp[:600] d_name = dept.department.name_ar or dept.department.name_en or dept.department.name if resp: dept_response_parts.append(f"- {d_name}: {resp}") if not dept_response_parts and complaint.action_taken_by_dept: dept_response_parts.append(complaint.action_taken_by_dept[:600]) dept_response_text = "\n".join(dept_response_parts) or "No department response recorded." content_summary = complaint.description or "" dept_response_summary = dept_response_text ComplaintPdfSummary.objects.update_or_create( complaint=complaint, defaults={ "lang": "ar", "content_summary": content_summary, "dept_response_summary": dept_response_summary, }, ) ctx = self._get_pdf_context_data(complaint) return Response({ "content_summary": content_summary, "dept_response_summary": dept_response_summary, **ctx, }) except Exception as e: logger.error(f"Error generating summary preview for complaint {pk}: {e}") return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @action(detail=True, methods=["post"]) def generate_summary_pdf(self, request, pk=None): """Generate a PDF report for the complaint in the requested language (en/ar).""" complaint = self.get_object() user = request.user can_generate = ( user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital) or (user.is_department_manager() and user.department == complaint.department) or (user.is_px_employee() and complaint.hospital == user.hospital) ) if not can_generate: return Response( {"error": "You do not have permission to generate a PDF summary"}, status=status.HTTP_403_FORBIDDEN, ) try: import base64 import json from django.conf import settings from django.template.loader import render_to_string lang = request.data.get("lang", "ar") from apps.complaints.models import ComplaintInvolvedDepartment dept_response_parts = [] involved_depts = ComplaintInvolvedDepartment.objects.filter( complaint=complaint ).select_related("department") for dept in involved_depts: resp = dept.response_notes_ar or dept.response_notes_en or dept.response_notes or "" resp = resp[:600] d_name = dept.department.name_ar or dept.department.name_en or dept.department.name if resp: dept_response_parts.append(f"- {d_name}: {resp}") if not dept_response_parts and complaint.action_taken_by_dept: dept_response_parts.append(complaint.action_taken_by_dept[:600]) dept_response_text = "\n".join(dept_response_parts) or "No department response recorded." resolution_text = complaint.resolution or complaint.recommendation_action_plan or "" # Use pre-written text from request, then saved summary, then AI fallback content_summary = request.data.get("content_summary") dept_response_summary = request.data.get("dept_response_summary") if not content_summary or not dept_response_summary: from .models import ComplaintPdfSummary saved = ComplaintPdfSummary.objects.filter(complaint=complaint).first() if saved and saved.content_summary and saved.dept_response_summary: content_summary = saved.content_summary dept_response_summary = saved.dept_response_summary else: import json prompt = f"""You are a healthcare complaint report writer. Generate your response entirely in Modern Standard Arabic (Fusha). COMPLAINT: - Title: {complaint.title} - Description: {complaint.description[:2000]} DEPARTMENT RESPONSE / ACTIONS TAKEN: {dept_response_text} RESOLUTION: {resolution_text[:600] or 'No resolution recorded.'} Generate JSON with exactly these fields: - "content_summary": 2-3 paragraph professional summary of the complaint content. - "department_response_summary": 1-2 paragraph summary of the department response and actions taken.""" from apps.core.ai_service import AIService result = AIService.chat_completion( prompt=prompt, response_format="json_object", temperature=0.3, max_tokens=1500, ) parsed = json.loads(result) content_summary = parsed.get("content_summary", complaint.description[:500] if complaint.description else "") dept_response_summary = parsed.get( "department_response_summary", complaint.action_taken_by_dept[:500] if complaint.action_taken_by_dept else "", ) ctx = self._get_pdf_context_data(complaint) for field in ["complainant_name", "source_name", "department_name", "accused_staff_name", "accused_staff_title", "submission_date", "incident_date", "sent_to_dept_date", "response_date"]: val = request.data.get(field) if val: ctx[field] = val import io from PIL import Image as PILImage def _img_to_data_uri(path): try: img = PILImage.open(path) buf = io.BytesIO() img.save(buf, format="PNG", optimize=True) return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() except Exception: return None _logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png") _logo_img.thumbnail((600, 600), PILImage.LANCZOS) _logo_buf = io.BytesIO() _logo_img.save(_logo_buf, format="PNG", optimize=True) logo_path = "data:image/png;base64," + base64.b64encode(_logo_buf.getvalue()).decode() # Per-hospital letterhead + stamp (with static fallback) letterhead_path = None if complaint.hospital and complaint.hospital.letterhead: letterhead_path = _img_to_data_uri(complaint.hospital.letterhead.path) if not letterhead_path: letterhead_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "artboard" / "Artboard 1@3x.png") stamp_path = None if complaint.hospital and complaint.hospital.stamp: stamp_path = _img_to_data_uri(complaint.hospital.stamp.path) if not stamp_path: stamp_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "stamps" / "stamp.png") # QR code encoding the public tracking URL for authenticity verification qr_code_path = None try: import qrcode from django.urls import reverse pdf_url = request.build_absolute_uri( reverse("complaints:public_complaint_pdf", kwargs={"reference_number": complaint.reference_number}) ) qr = qrcode.QRCode(version=1, box_size=4, border=1) qr.add_data(pdf_url) qr.make(fit=True) qr_img = qr.make_image(fill_color="black", back_color="white") qr_buf = io.BytesIO() qr_img.save(qr_buf, format="PNG") qr_code_path = "data:image/png;base64," + base64.b64encode(qr_buf.getvalue()).decode() except Exception: pass html_string = render_to_string( "complaints/complaint_summary_pdf.html", { "complaint": complaint, "lang": "ar", "content_summary": content_summary, "dept_response_summary": dept_response_summary, "logo_path": logo_path, "letterhead_path": letterhead_path, "stamp_path": stamp_path, "qr_code_path": qr_code_path, "hospital_name_en": complaint.hospital.get_display_name() if complaint.hospital else "", "hospital_address": complaint.hospital.address if complaint.hospital else "", "hospital_phone": complaint.hospital.phone if complaint.hospital else "", "hospital_email": complaint.hospital.email if complaint.hospital else "", **ctx, }, ) from weasyprint import HTML pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf() # Save PDF to persistent storage from django.core.files.base import ContentFile from .models import ComplaintPdfSummary summary, _ = ComplaintPdfSummary.objects.update_or_create( complaint=complaint, defaults={ "lang": "ar", "content_summary": content_summary, "dept_response_summary": dept_response_summary, }, ) summary.file.save(f"{complaint.pk}_ar.pdf", ContentFile(pdf_file), save=False) summary.file_size = len(pdf_file) summary.save() from django.http import HttpResponse from datetime import datetime timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"complaint_summary_{complaint.reference_number}_ar_{timestamp}.pdf" response = HttpResponse(pdf_file, content_type="application/pdf") response["Content-Disposition"] = f'attachment; filename="{filename}"' AuditService.log_from_request( event_type="pdf_summary_generated", description=f"PDF report ({lang}) generated for complaint: {complaint.reference_number}", request=request, content_object=complaint, metadata={"complaint_id": str(pk), "lang": lang}, ) return response except ImportError: return Response({"error": "WeasyPrint is not installed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except Exception as e: logger.error(f"Error generating PDF summary for complaint {pk}: {e}") return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @action(detail=True, methods=["post"]) def save_resolution(self, request, pk=None): """ Save final resolution for the complaint. Allows user to save an edited or directly generated resolution. Optionally updates complaint status to RESOLVED. """ complaint = self.get_object() # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( {"error": "Only PX Admins or Hospital Admins can save resolutions"}, status=status.HTTP_403_FORBIDDEN ) resolution_text = request.data.get("resolution") mark_resolved = request.data.get("mark_resolved", False) if not resolution_text: return Response({"error": "Resolution text is required"}, status=status.HTTP_400_BAD_REQUEST) # Save resolution complaint.resolution = resolution_text complaint.resolution_category = ComplaintResolutionCategory.FULL_ACTION_TAKEN if mark_resolved: complaint.status = ComplaintStatus.RESOLVED complaint.resolved_at = timezone.now() complaint.resolved_by = request.user complaint.save() # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type="resolution", message=f"Resolution added{' and complaint marked as resolved' if mark_resolved else ''}", created_by=request.user, metadata={ "resolution_category": ComplaintResolutionCategory.FULL_ACTION_TAKEN, "mark_resolved": mark_resolved, }, ) # Log audit AuditService.log_from_request( event_type="resolution_saved", description=f"Resolution saved{' and complaint resolved' if mark_resolved else ''}", request=request, content_object=complaint, metadata={"mark_resolved": mark_resolved}, ) return Response( { "success": True, "message": f"Resolution saved successfully{' and complaint marked as resolved' if mark_resolved else ''}", "complaint_id": str(complaint.id), "status": complaint.status, } ) @action(detail=True, methods=["post"]) def convert_to_appreciation(self, request, pk=None): """ Convert complaint to appreciation. Creates an Appreciation record from a complaint marked as 'appreciation' type. Maps complaint data to appreciation fields and links both records. Optionally closes the complaint after conversion. """ complaint = self.get_object() # Check if complaint is in active status if not complaint.is_active_status: return Response( { "error": f"Cannot convert complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved." }, status=status.HTTP_400_BAD_REQUEST, ) # Check if complaint is appreciation type if complaint.complaint_type != "appreciation": return Response( {"error": "Only appreciation-type complaints can be converted to appreciations"}, status=status.HTTP_400_BAD_REQUEST, ) # Check if already converted if complaint.metadata.get("appreciation_id"): return Response( {"error": "This complaint has already been converted to an appreciation"}, status=status.HTTP_400_BAD_REQUEST, ) # Get form data recipient_type = request.data.get("recipient_type", "user") # 'user' or 'physician' recipient_id = request.data.get("recipient_id") category_id = request.data.get("category_id") message_en = request.data.get("message_en", complaint.description) message_ar = request.data.get("message_ar", complaint.short_description_ar or "") visibility = request.data.get("visibility", "private") is_anonymous = request.data.get("is_anonymous", True) close_complaint = request.data.get("close_complaint", False) # Validate recipient from django.contrib.contenttypes.models import ContentType if recipient_type == "user": from apps.accounts.models import User try: recipient_user = User.objects.get(id=recipient_id) recipient_content_type = ContentType.objects.get_for_model(User) recipient_object_id = recipient_user.id except User.DoesNotExist: return Response({"error": "Recipient user not found"}, status=status.HTTP_404_NOT_FOUND) elif recipient_type == "physician": from apps.physicians.models import Physician try: recipient_physician = Physician.objects.get(id=recipient_id) recipient_content_type = ContentType.objects.get_for_model(Physician) recipient_object_id = recipient_physician.id except Physician.DoesNotExist: return Response({"error": "Recipient physician not found"}, status=status.HTTP_404_NOT_FOUND) else: return Response( {"error": 'Invalid recipient_type. Must be "user" or "physician"'}, status=status.HTTP_400_BAD_REQUEST ) # Validate category from apps.appreciation.models import AppreciationCategory try: category = AppreciationCategory.objects.get(id=category_id) except AppreciationCategory.DoesNotExist: return Response({"error": "Appreciation category not found"}, status=status.HTTP_404_NOT_FOUND) # Determine sender (patient or anonymous) sender = None if not is_anonymous and complaint.patient and complaint.patient.user: sender = complaint.patient.user # Create Appreciation from apps.appreciation.models import Appreciation appreciation = Appreciation.objects.create( sender=sender, recipient_content_type=recipient_content_type, recipient_object_id=recipient_object_id, hospital=complaint.hospital, department=complaint.department, category=category, message_en=message_en, message_ar=message_ar, visibility=visibility, status=Appreciation.AppreciationStatus.DRAFT, is_anonymous=is_anonymous, metadata={ "source_complaint_id": str(complaint.id), "source_complaint_title": complaint.title, "converted_from_complaint": True, "converted_by": str(request.user.id), "converted_at": timezone.now().isoformat(), }, ) # Activate + send appreciation (triggers notification). # activate() honors the activation gate (DRAFT -> ACTIVATED) and records # who approved it; send() then moves ACTIVATED -> SENT. appreciation.activate(activated_by=request.user) appreciation.send() # Link appreciation to complaint if not complaint.metadata: complaint.metadata = {} complaint.metadata["appreciation_id"] = str(appreciation.id) complaint.metadata["converted_to_appreciation"] = True complaint.metadata["converted_to_appreciation_at"] = timezone.now().isoformat() complaint.metadata["converted_by"] = str(request.user.id) complaint.save(update_fields=["metadata"]) # Close complaint if requested complaint_closed = False if close_complaint: complaint.status = "closed" complaint.closed_at = timezone.now() complaint.closed_by = request.user complaint.save(update_fields=["status", "closed_at", "closed_by"]) complaint_closed = True # Create status update ComplaintUpdate.objects.create( complaint=complaint, update_type="status_change", message="Complaint closed after converting to appreciation", created_by=request.user, old_status="open", new_status="closed", ) # Create conversion update ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Converted to appreciation (Appreciation #{appreciation.id})", created_by=request.user, metadata={ "appreciation_id": str(appreciation.id), "converted_from_complaint": True, "close_complaint": close_complaint, }, ) # Log audit AuditService.log_from_request( event_type="complaint_converted_to_appreciation", description=f"Complaint converted to appreciation: {appreciation.message_en[:100]}", request=request, content_object=complaint, metadata={ "appreciation_id": str(appreciation.id), "close_complaint": close_complaint, "is_anonymous": is_anonymous, }, ) # Build appreciation URL from django.contrib.sites.shortcuts import get_current_site site = get_current_site(request) appreciation_url = f"https://{site.domain}/appreciations/{appreciation.id}/" return Response( { "success": True, "message": "Complaint successfully converted to appreciation", "appreciation_id": str(appreciation.id), "appreciation_url": appreciation_url, "complaint_closed": complaint_closed, }, status=status.HTTP_201_CREATED, ) @action(detail=True, methods=["post"]) def send_resolution_notification(self, request, pk=None): """ Send resolution notification to patient. Sends email notification to patient with resolution details. Optionally sends SMS if phone number is available. Creates ComplaintUpdate entry and logs audit trail. """ complaint = self.get_object() # Check if complaint is resolved if complaint.status != "resolved": return Response( {"error": "Can only send resolution notification for resolved complaints"}, status=status.HTTP_400_BAD_REQUEST, ) # Check if resolution exists if not complaint.resolution: return Response( {"error": "Complaint must have resolution details before sending notification"}, status=status.HTTP_400_BAD_REQUEST, ) # Determine recipient (patient or contact) recipient_email = None recipient_phone = None recipient_name = None # Try patient first if complaint.patient: if complaint.patient.email: recipient_email = complaint.patient.email if complaint.patient.phone: recipient_phone = complaint.patient.phone recipient_name = complaint.patient.get_full_name() # Fall back to contact info if not recipient_email: recipient_email = complaint.contact_email if not recipient_name: recipient_name = complaint.contact_name if not recipient_phone: recipient_phone = complaint.contact_phone # Validate at least email is available if not recipient_email: return Response( {"error": "No email address found for patient or contact"}, status=status.HTTP_400_BAD_REQUEST ) # Build email subject and body subject = f"Complaint Resolution - #{complaint.id}" # Build email body email_body = f""" Dear {recipient_name}, We are pleased to inform you that your complaint has been resolved. COMPLAINT DETAILS: ---------------- Reference: #{complaint.id} Title: {complaint.title} Status: {complaint.get_status_display()} RESOLUTION: ----------- Category: {complaint.get_resolution_category_display()} {complaint.resolution} """ # Add additional context if available if complaint.resolved_by: email_body += f""" Resolved by: {complaint.resolved_by.get_full_name()} Resolved at: {complaint.resolved_at.strftime("%Y-%m-%d %H:%M")} """ email_body += f""" If you have any further questions or concerns about this resolution, please don't hesitate to contact us. Thank you for your patience and for giving us the opportunity to address your concerns. --- This is an automated message from PX360 Complaint Management System. """ # Send email using NotificationService from apps.notifications.services import NotificationService, get_email_header_html try: notification_log = NotificationService.send_email( email=recipient_email, subject=subject, message=email_body, html_message=f"""
{get_email_header_html()}

Complaint Resolution

Dear {recipient_name},

We are pleased to inform you that your complaint has been resolved.

Reference:#{complaint.id}
Title:{complaint.title}
Category:{complaint.get_resolution_category_display()}

{complaint.resolution}

If you have any further questions or concerns, please don't hesitate to contact us.

Thank you for your patience and for giving us the opportunity to address your concerns.

""", related_object=complaint, metadata={ "notification_type": "resolution_notification", "recipient_name": recipient_name, "recipient_phone": recipient_phone, "sender_id": str(request.user.id), "resolution_category": complaint.resolution_category, }, ) except Exception as e: return Response({"error": f"Failed to send email: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # Optionally send SMS if phone is available sms_sent = False if recipient_phone: try: # Build SMS message (shorter) sms_message = f"PX360: Your complaint #{complaint.id} has been resolved. Resolution Category: {complaint.get_resolution_category_display()}. Check your email for details." # Send SMS (if SMS service is configured) # This is a placeholder - actual SMS sending depends on your SMS provider sms_sent = True # Set to True if SMS is actually sent if sms_sent: # Log SMS in metadata complaint.metadata["resolution_sms_sent_at"] = timezone.now().isoformat() complaint.metadata["resolution_sms_sent_to"] = recipient_phone complaint.save(update_fields=["metadata"]) except Exception as e: # Log error but don't fail the operation import logging logger = logging.getLogger(__name__) logger.error(f"Failed to send SMS: {e}") # Create ComplaintUpdate entry ComplaintUpdate.objects.create( complaint=complaint, update_type="communication", message=f"Resolution notification sent to {recipient_name}", created_by=request.user, metadata={ "notification_type": "resolution_notification", "recipient_name": recipient_name, "recipient_email": recipient_email, "notification_log_id": str(notification_log.id) if notification_log else None, "sms_sent": sms_sent, }, ) # Log audit AuditService.log_from_request( event_type="resolution_notification_sent", description=f"Resolution notification sent to {recipient_name}", request=request, content_object=complaint, metadata={ "recipient_name": recipient_name, "recipient_email": recipient_email, "recipient_phone": recipient_phone, "sms_sent": sms_sent, "resolution_category": complaint.resolution_category, }, ) return Response( { "success": True, "message": "Resolution notification sent successfully", "recipient": recipient_name, "recipient_email": recipient_email, "sms_sent": sms_sent, } ) @action(detail=True, methods=["post"]) def update_taxonomy(self, request, pk=None): """ Update the 4-level SHCT taxonomy classification for a complaint. Allows PX Admins and Hospital Admins to manually correct or update the AI-generated taxonomy classification. Required fields: - domain_id: UUID of the Level 1 Domain (ComplaintCategory) - category_id: UUID of the Level 2 Category (ComplaintCategory) - subcategory_id: UUID of the Level 3 Subcategory (ComplaintCategory) - classification_id: UUID of the Level 4 Classification (ComplaintCategory) Optional fields: - note: Optional note explaining the change """ complaint = self.get_object() user = request.user # Check permissions if not (user.is_px_admin() or user.is_hospital_admin()): return Response( {"error": "Only PX Admins and Hospital Admins can update taxonomy"}, status=status.HTTP_403_FORBIDDEN ) # Get taxonomy IDs from request domain_id = request.data.get("domain_id") category_id = request.data.get("category_id") subcategory_id = request.data.get("subcategory_id") classification_id = request.data.get("classification_id") note = request.data.get("note", "") # Validate that at least one field is provided if not any([domain_id, category_id, subcategory_id, classification_id]): return Response( { "error": "At least one taxonomy level (domain_id, category_id, subcategory_id, or classification_id) must be provided" }, status=status.HTTP_400_BAD_REQUEST, ) from apps.complaints.models import ComplaintCategory changes = [] errors = [] # Store old values for logging old_domain = complaint.domain old_category = complaint.category old_subcategory_obj = complaint.subcategory_obj old_classification_obj = complaint.classification_obj try: # Level 1: Domain if domain_id: try: domain = ComplaintCategory.objects.get( id=domain_id, level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True ) complaint.domain = domain changes.append(f"Domain: {old_domain.name_en if old_domain else 'None'} -> {domain.name_en}") except ComplaintCategory.DoesNotExist: errors.append(f"Domain with ID {domain_id} not found or not active") # Level 2: Category (must be child of domain if domain is set) if category_id: try: category_query = ComplaintCategory.objects.filter( id=category_id, level=ComplaintCategory.LevelChoices.CATEGORY, is_active=True ) # If domain is set, ensure category is child of domain if complaint.domain: category_query = category_query.filter(parent=complaint.domain) category = category_query.first() if category: complaint.category = category changes.append( f"Category: {old_category.name_en if old_category else 'None'} -> {category.name_en}" ) else: errors.append( f"Category with ID {category_id} not found, not active, or not under the selected domain" ) except Exception as e: errors.append(f"Error setting category: {str(e)}") # Level 3: Subcategory (must be child of category if category is set) if subcategory_id: try: subcategory_query = ComplaintCategory.objects.filter( id=subcategory_id, level=ComplaintCategory.LevelChoices.SUBCATEGORY, is_active=True ) # If category is set, ensure subcategory is child of category if complaint.category: subcategory_query = subcategory_query.filter(parent=complaint.category) subcategory = subcategory_query.first() if subcategory: complaint.subcategory_obj = subcategory complaint.subcategory = subcategory.code or subcategory.name_en changes.append( f"Subcategory: {old_subcategory_obj.name_en if old_subcategory_obj else 'None'} -> {subcategory.name_en}" ) else: errors.append( f"Subcategory with ID {subcategory_id} not found, not active, or not under the selected category" ) except Exception as e: errors.append(f"Error setting subcategory: {str(e)}") # Level 4: Classification (must be child of subcategory if subcategory is set) if classification_id: try: classification_query = ComplaintCategory.objects.filter( id=classification_id, level=ComplaintCategory.LevelChoices.CLASSIFICATION, is_active=True ) # If subcategory_obj is set, ensure classification is child of subcategory if complaint.subcategory_obj: classification_query = classification_query.filter(parent=complaint.subcategory_obj) classification = classification_query.first() if classification: complaint.classification_obj = classification complaint.classification = classification.code or classification.name_en changes.append( f"Classification: {old_classification_obj.name_en if old_classification_obj else 'None'} -> {classification.name_en}" ) else: errors.append( f"Classification with ID {classification_id} not found, not active, or not under the selected subcategory" ) except Exception as e: errors.append(f"Error setting classification: {str(e)}") # If there were errors, return them without saving if errors: return Response( {"error": "Some taxonomy levels could not be updated", "errors": errors, "changes_made": changes}, status=status.HTTP_400_BAD_REQUEST, ) # Save the complaint complaint.save( update_fields=[ "domain", "category", "subcategory", "subcategory_obj", "classification", "classification_obj", ] ) # Create timeline entry change_message = "Taxonomy updated:\n" + "\n".join(changes) if note: change_message += f"\n\nNote: {note}" ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=change_message, created_by=user, metadata={"taxonomy_update": True, "changes": changes, "note": note, "updated_by": str(user.id)}, ) # Log audit AuditService.log_from_request( event_type="taxonomy_updated", description=f"Taxonomy updated for complaint: {complaint.title}", request=request, content_object=complaint, metadata={"changes": changes, "note": note, "updated_by": str(user.id)}, ) # Update metadata to reflect manual update if not complaint.metadata: complaint.metadata = {} if "ai_analysis" not in complaint.metadata: complaint.metadata["ai_analysis"] = {} complaint.metadata["ai_analysis"]["taxonomy_manually_updated"] = True complaint.metadata["ai_analysis"]["taxonomy_updated_by"] = str(user.id) complaint.metadata["ai_analysis"]["taxonomy_updated_at"] = timezone.now().isoformat() complaint.save(update_fields=["metadata"]) return Response( { "success": True, "message": "Taxonomy updated successfully", "changes": changes, "taxonomy": { "domain": { "id": str(complaint.domain.id) if complaint.domain else None, "name_en": complaint.domain.name_en if complaint.domain else None, "name_ar": complaint.domain.name_ar if complaint.domain else None, }, "category": { "id": str(complaint.category.id) if complaint.category else None, "name_en": complaint.category.name_en if complaint.category else None, "name_ar": complaint.category.name_ar if complaint.category else None, }, "subcategory": { "id": str(complaint.subcategory_obj.id) if complaint.subcategory_obj else None, "name_en": complaint.subcategory_obj.name_en if complaint.subcategory_obj else None, "name_ar": complaint.subcategory_obj.name_ar if complaint.subcategory_obj else None, "code": complaint.subcategory, }, "classification": { "id": str(complaint.classification_obj.id) if complaint.classification_obj else None, "name_en": complaint.classification_obj.name_en if complaint.classification_obj else None, "name_ar": complaint.classification_obj.name_ar if complaint.classification_obj else None, "code": complaint.classification, }, }, } ) except Exception as e: logger.error(f"Error updating taxonomy: {str(e)}") return Response( {"error": f"Failed to update taxonomy: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @action(detail=True, methods=["get"]) def taxonomy_options(self, request, pk=None): """ Get available taxonomy options for the complaint's hierarchy. Returns the full SHCT taxonomy hierarchy for building cascading dropdowns. Includes only active categories. """ complaint = self.get_object() from apps.complaints.models import ComplaintCategory from django.db.models import Prefetch # Build the hierarchy domains = ComplaintCategory.objects.filter( level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True ).order_by("order", "name_en") result = [] for domain in domains: domain_data = { "id": str(domain.id), "code": domain.code or domain.name_en.upper(), "name_en": domain.name_en, "name_ar": domain.name_ar, "is_selected": complaint.domain and complaint.domain.id == domain.id, "categories": [], } # Get categories for this domain categories = ComplaintCategory.objects.filter( parent=domain, level=ComplaintCategory.LevelChoices.CATEGORY, is_active=True ).order_by("order", "name_en") for category in categories: category_data = { "id": str(category.id), "code": category.code or category.name_en.upper(), "name_en": category.name_en, "name_ar": category.name_ar, "is_selected": complaint.category and complaint.category.id == category.id, "subcategories": [], } # Get subcategories for this category subcategories = ComplaintCategory.objects.filter( parent=category, level=ComplaintCategory.LevelChoices.SUBCATEGORY, is_active=True ).order_by("order", "name_en") for subcategory in subcategories: subcategory_data = { "id": str(subcategory.id), "code": subcategory.code or subcategory.name_en.upper(), "name_en": subcategory.name_en, "name_ar": subcategory.name_ar, "is_selected": complaint.subcategory_obj and complaint.subcategory_obj.id == subcategory.id, "classifications": [], } # Get classifications for this subcategory classifications = ComplaintCategory.objects.filter( parent=subcategory, level=ComplaintCategory.LevelChoices.CLASSIFICATION, is_active=True ).order_by("order", "name_en") for classification in classifications: classification_data = { "id": str(classification.id), "code": classification.code, "name_en": classification.name_en, "name_ar": classification.name_ar, "is_selected": complaint.classification_obj and complaint.classification_obj.id == classification.id, } subcategory_data["classifications"].append(classification_data) category_data["subcategories"].append(subcategory_data) domain_data["categories"].append(category_data) result.append(domain_data) return Response( { "success": True, "hierarchy": result, "current": { "domain_id": str(complaint.domain.id) if complaint.domain else None, "category_id": str(complaint.category.id) if complaint.category else None, "subcategory_id": str(complaint.subcategory_obj.id) if complaint.subcategory_obj else None, "classification_id": str(complaint.classification_obj.id) if complaint.classification_obj else None, }, } ) @action(detail=True, methods=["post"]) def reanalyze_ai(self, request, pk=None): """Trigger re-analysis of a complaint with AI (synchronous - waits for result)""" complaint = self.get_object() user = request.user can_reanalyze = user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital) if not can_reanalyze: return Response( {"error": "You do not have permission to re-analyze this complaint"}, status=status.HTTP_403_FORBIDDEN, ) try: from apps.core.ai_service import AIService from apps.complaints.tasks import _apply_complaint_ai_analysis from apps.complaints.models import ComplaintUpdate category_name = None if complaint.category: category_name = complaint.category.name_en analysis = AIService.analyze_complaint( title=complaint.title, description=complaint.description, category=category_name, hospital_id=complaint.hospital.id, ) emotion_analysis = AIService.analyze_emotion(text=complaint.description) result = _apply_complaint_ai_analysis(complaint, analysis, emotion_analysis) ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message="AI re-analysis complete.", created_by=request.user, ) AuditService.log_from_request( event_type="complaint_reanalyzed", description=f"Complaint {complaint.reference_number} re-analyzed with AI", request=request, content_object=complaint, ) emotion_map = { "anger": "Anger", "sadness": "Sadness", "confusion": "Confusion", "fear": "Fear", "neutral": "Neutral", } badge_map = { "anger": "danger", "sadness": "primary", "confusion": "warning", "fear": "info", "neutral": "secondary", } return Response( { "success": True, "severity": result.get("severity", ""), "severity_display": complaint.get_severity_display(), "priority": result.get("priority", ""), "priority_display": complaint.get_priority_display(), "emotion": result.get("emotion", "neutral"), "emotion_display": emotion_map.get(result.get("emotion", "neutral"), "Neutral"), "emotion_badge_class": badge_map.get(result.get("emotion", "neutral"), "secondary"), "emotion_intensity": result.get("emotion_intensity", 0.0), "emotion_intensity_percent": result.get("emotion_intensity", 0.0) * 100, "emotion_confidence": result.get("emotion_confidence", 0.0), "emotion_confidence_percent": result.get("emotion_confidence", 0.0) * 100, "short_description_en": result.get("short_description_en", ""), "short_description_ar": result.get("short_description_ar", ""), "suggested_actions": result.get("suggested_actions", []), "suggested_action_en": result.get("suggested_action_en", ""), "suggested_action_ar": result.get("suggested_action_ar", ""), } ) except Exception as e: logger.error(f"AI re-analysis failed for complaint {pk}: {e}") return Response( {"success": False, "error": f"AI analysis failed: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class ComplaintAttachmentViewSet(viewsets.ModelViewSet): """ViewSet for Complaint Attachments""" queryset = ComplaintAttachment.objects.all() serializer_class = ComplaintAttachmentSerializer permission_classes = [IsAuthenticated] filterset_fields = ["complaint"] ordering = ["-created_at"] def get_queryset(self): queryset = super().get_queryset().select_related("complaint", "uploaded_by") user = self.request.user # Filter based on complaint access if user.is_px_admin(): return queryset if user.is_hospital_admin() and user.hospital: return queryset.filter(complaint__hospital=user.hospital) if user.hospital: return queryset.filter(complaint__hospital=user.hospital) return queryset.none() class InquiryViewSet(viewsets.ModelViewSet): """ViewSet for Inquiries""" queryset = Inquiry.objects.all() serializer_class = InquirySerializer permission_classes = [IsAuthenticated] filterset_fields = [ "status", "category", "source", "hospital", "department", "assigned_to", "hospital__organization", ] search_fields = ["subject", "message", "contact_name", "patient__mrn"] ordering_fields = ["created_at"] ordering = ["-created_at"] def perform_create(self, serializer): """Auto-set created_by from request.user and trigger AI analysis in background""" inquiry = serializer.save(created_by=self.request.user) from apps.complaints.tasks import analyze_inquiry_with_ai, notify_staff_new_item analyze_inquiry_with_ai.delay(str(inquiry.id)) notify_staff_new_item.delay("inquiry", str(inquiry.id)) AuditService.log_from_request( event_type="inquiry_created", description=f"Inquiry created: {inquiry.subject}", request=self.request, content_object=inquiry, metadata={"created_by": str(inquiry.created_by.id) if inquiry.created_by else None}, ) def get_queryset(self): """Filter inquiries based on user role""" queryset = ( super() .get_queryset() .select_related("patient", "hospital", "department", "assigned_to", "responded_by", "created_by") ) user = self.request.user # PX Admins see all inquiries if user.is_px_admin(): return queryset # Source Users see ONLY inquiries THEY created if hasattr(user, "source_user_profile") and user.source_user_profile.exists(): return queryset.filter(created_by=user) # Patients see ONLY their own inquiries (if they have user accounts) if hasattr(user, "patient_profile"): return queryset.filter(patient__user=user) # Hospital Admins see inquiries for their hospital if user.is_hospital_admin() and user.hospital: return queryset.filter(hospital=user.hospital) # Department Managers see inquiries for their department if user.is_department_manager() and user.department: return queryset.filter(department=user.department) # Others see inquiries for their hospital if user.hospital: return queryset.filter(hospital=user.hospital) return queryset.none() @action(detail=True, methods=["post"]) def respond(self, request, pk=None): """Respond to inquiry""" inquiry = self.get_object() response_text = request.data.get("response") if not response_text: return Response({"error": "response is required"}, status=status.HTTP_400_BAD_REQUEST) inquiry.response = response_text inquiry.responded_at = timezone.now() inquiry.responded_by = request.user inquiry.status = "resolved" inquiry._acting_user = request.user inquiry.save() return Response({"message": "Response submitted successfully"}) @action(detail=True, methods=["post"]) def generate_ai_response(self, request, pk=None): """ Generate AI-powered response for an inquiry in both English and Arabic. Admin can edit the generated response before sending to the inquirer. """ inquiry = self.get_object() user = request.user can_generate = ( user.is_px_admin() or user.is_px_management() or user.is_px_employee() or (user.is_hospital_admin() and user.hospital == inquiry.hospital) or inquiry.assigned_to == user ) if not can_generate: return Response( {"error": "You do not have permission to generate AI response for this inquiry"}, status=status.HTTP_403_FORBIDDEN, ) try: from apps.core.ai_service import AIService ai_description_en = "" ai_description_ar = "" if inquiry.metadata and "ai_analysis" in inquiry.metadata: ai_description_en = inquiry.metadata["ai_analysis"].get("short_description_en", "") ai_description_ar = inquiry.metadata["ai_analysis"].get("short_description_ar", "") category_display = inquiry.get_category_display() hospital_name = inquiry.hospital.name if inquiry.hospital else "Unknown" dept_response_section = "" if inquiry.department_response_en or inquiry.department_response_ar: dept_response_en = inquiry.department_response_en or "" dept_response_ar = inquiry.department_response_ar or "" dept_summary_en = inquiry.department_response_summary_en or "" dept_summary_ar = inquiry.department_response_summary_ar or "" dept_response_section = f""" DEPARTMENT/STAFF RESPONSE (use this as the primary basis for your answer): - English: {dept_summary_en or dept_response_en} - Arabic: {dept_summary_ar or dept_response_ar} IMPORTANT: The department has already provided a technical response above. Your task is to transform it into a clear, patient-friendly response. Keep the factual information and actions taken, but rephrase in simple, empathetic language a patient would understand. Do NOT omit any important details from the department response.""" prompt = f"""As a healthcare inquiry response specialist, generate a professional response to this inquiry in BOTH English and Arabic. INQUIRY DETAILS: - Subject: {inquiry.subject} - Message: {inquiry.message} - Category: {category_display} - Hospital: {hospital_name} - Inquirer Name: {inquiry.contact_name or "Unknown"} AI SUMMARY (for context): - English: {ai_description_en} - Arabic: {ai_description_ar} {dept_response_section} Based on the above information, generate a professional response that: 1. Acknowledges the inquiry and thanks the inquirer 2. Directly addresses the question/request 3. Provides helpful, accurate information 4. Offers further assistance if needed 5. Uses a professional, empathetic tone IMPORTANT: Provide the response in BOTH languages as JSON: {{ "response_en": "The response text in English (2-4 paragraphs)", "response_ar": "نص الرد بالعربية (2-4 فقرات)" }} Ensure both versions convey the same meaning and are professionally written for healthcare settings.""" system_prompt = """You are an expert healthcare inquiry response specialist fluent in both English and Arabic. Your task is to generate comprehensive, professional responses to patient inquiries in both languages. Be helpful, accurate, and empathetic. Use professional tone appropriate for healthcare. Always provide valid JSON output with both response_en and response_ar fields. Use Modern Standard Arabic (Fusha) - الفصحى.""" ai_response = AIService.chat_completion( prompt=prompt, system_prompt=system_prompt, temperature=0.4, max_tokens=1500, response_format="json_object", ) import json response_data = json.loads(ai_response) response_en = response_data.get("response_en", "").strip() response_ar = response_data.get("response_ar", "").strip() AuditService.log_from_request( event_type="ai_inquiry_response_generated", description=f"AI response generated for inquiry {inquiry.reference_number}", request=request, content_object=inquiry, metadata={ "inquiry_id": str(inquiry.id), "generated_response_en_length": len(response_en), "generated_response_ar_length": len(response_ar), }, ) return Response( { "success": True, "response_en": response_en, "response_ar": response_ar, } ) except Exception as e: logger.error(f"AI inquiry response generation failed: {e}") return Response( {"success": False, "error": f"Failed to generate response: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @action(detail=True, methods=["post"]) def reanalyze_ai(self, request, pk=None): """Trigger re-analysis of an inquiry with AI (synchronous - waits for result)""" inquiry = self.get_object() user = request.user can_reanalyze = ( user.is_px_admin() or (user.is_hospital_admin() and user.hospital == inquiry.hospital) or inquiry.assigned_to == user ) if not can_reanalyze: return Response( {"error": "You do not have permission to re-analyze this inquiry"}, status=status.HTTP_403_FORBIDDEN, ) try: from apps.core.ai_service import AIService, AIServiceError from apps.complaints.tasks import _apply_inquiry_ai_analysis from apps.complaints.models import InquiryUpdate analysis = AIService.analyze_inquiry( subject=inquiry.subject, message=inquiry.message, category=inquiry.get_category_display(), hospital_id=inquiry.hospital.id, ) emotion_analysis = AIService.analyze_emotion(text=inquiry.message) _apply_inquiry_ai_analysis(inquiry, analysis, emotion_analysis) InquiryUpdate.objects.create( inquiry=inquiry, update_type="note", message="AI re-analysis complete.", created_by=request.user, ) AuditService.log_from_request( event_type="inquiry_reanalyzed", description=f"Inquiry {inquiry.reference_number} re-analyzed with AI", request=request, content_object=inquiry, ) return Response( { "success": True, "priority": inquiry.priority, "priority_display": inquiry.get_priority_display(), "emotion": emotion_analysis.get("emotion", "neutral"), "emotion_display": { "anger": "Anger", "sadness": "Sadness", "confusion": "Confusion", "fear": "Fear", "neutral": "Neutral", }.get(emotion_analysis.get("emotion", "neutral"), "Neutral"), "emotion_badge_class": { "anger": "danger", "sadness": "primary", "confusion": "warning", "fear": "info", "neutral": "secondary", }.get(emotion_analysis.get("emotion", "neutral"), "secondary"), "emotion_intensity": emotion_analysis.get("intensity", 0.0), "emotion_intensity_percent": emotion_analysis.get("intensity", 0.0) * 100, "emotion_confidence": emotion_analysis.get("confidence", 0.0), "emotion_confidence_percent": emotion_analysis.get("confidence", 0.0) * 100, "short_description_en": analysis.get("short_description_en", ""), "short_description_ar": analysis.get("short_description_ar", ""), } ) except Exception as e: logger.error(f"AI re-analysis failed for inquiry {pk}: {e}") return Response( {"success": False, "error": f"AI analysis failed: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class ComplaintPRInteractionViewSet(viewsets.ModelViewSet): """ViewSet for PR Interactions""" queryset = ComplaintPRInteraction.objects.all() serializer_class = ComplaintPRInteractionSerializer permission_classes = [IsAuthenticated] filterset_fields = ["complaint", "contact_method", "procedure_explained", "pr_staff"] ordering = ["-contact_date"] def get_queryset(self): queryset = super().get_queryset().select_related("complaint", "pr_staff", "created_by") user = self.request.user # Filter based on complaint access if user.is_px_admin(): return queryset if user.is_hospital_admin() and user.hospital: return queryset.filter(complaint__hospital=user.hospital) if user.hospital: return queryset.filter(complaint__hospital=user.hospital) return queryset.none() def perform_create(self, serializer): """Auto-set created_by from request.user""" interaction = serializer.save(created_by=self.request.user) # Create complaint update ComplaintUpdate.objects.create( complaint=interaction.complaint, update_type="note", message=f"PR Interaction recorded: Contact via {interaction.get_contact_method_display()}", created_by=self.request.user, metadata={ "interaction_id": str(interaction.id), "contact_method": interaction.contact_method, "procedure_explained": interaction.procedure_explained, }, ) AuditService.log_from_request( event_type="pr_interaction_created", description=f"PR Interaction recorded for complaint: {interaction.complaint.title}", request=self.request, content_object=interaction, metadata={"complaint_id": str(interaction.complaint.id), "contact_method": interaction.contact_method}, ) class ComplaintMeetingViewSet(viewsets.ModelViewSet): """ViewSet for Complaint Meetings""" queryset = ComplaintMeeting.objects.all() serializer_class = ComplaintMeetingSerializer permission_classes = [IsAuthenticated] filterset_fields = ["complaint", "meeting_type"] ordering = ["-meeting_date"] def get_queryset(self): queryset = super().get_queryset().select_related("complaint", "created_by") user = self.request.user # Filter based on complaint access if user.is_px_admin(): return queryset if user.is_hospital_admin() and user.hospital: return queryset.filter(complaint__hospital=user.hospital) if user.hospital: return queryset.filter(complaint__hospital=user.hospital) return queryset.none() def perform_create(self, serializer): """Auto-set created_by from request.user""" meeting = serializer.save(created_by=self.request.user) # Create complaint update ComplaintUpdate.objects.create( complaint=meeting.complaint, update_type="note", message=f"Meeting recorded: {meeting.get_meeting_type_display()} - {meeting.outcome[:100] if meeting.outcome else ''}", created_by=self.request.user, metadata={"meeting_id": str(meeting.id), "meeting_type": meeting.meeting_type}, ) # If outcome is provided, consider it as resolution if meeting.outcome and meeting.complaint.status not in ["resolved", "closed"]: meeting.complaint.status = "resolved" meeting.complaint.resolution = meeting.outcome meeting.complaint.resolved_at = timezone.now() meeting.complaint.resolved_by = self.request.user meeting.complaint.save(update_fields=["status", "resolution", "resolved_at", "resolved_by"]) # Create status update ComplaintUpdate.objects.create( complaint=meeting.complaint, update_type="status_change", message=f"Complaint resolved through meeting", created_by=self.request.user, old_status="in_progress", new_status="resolved", ) AuditService.log_from_request( event_type="meeting_created", description=f"Complaint Meeting recorded for: {meeting.complaint.title}", request=self.request, content_object=meeting, metadata={"complaint_id": str(meeting.complaint.id), "meeting_type": meeting.meeting_type}, ) # Public views (no authentication required) from django.shortcuts import render, redirect, get_object_or_404 from django.http import JsonResponse from django.views.decorators.http import require_GET from django.views.decorators.csrf import csrf_exempt def api_locations(request): """ API endpoint to get all locations for complaint form. Returns JSON list of all locations ordered by English name. Public endpoint (no authentication required). """ from apps.organizations.models import LegacyLocation locations = LegacyLocation.objects.all().order_by("name_en") locations_list = [ { "id": loc.id, "name": str(loc), # Uses __str__ which prefers English name } for loc in locations ] return JsonResponse({"success": True, "locations": locations_list, "count": len(locations_list)}) @require_GET def api_sections(request, location_id): """ API endpoint to get sections for a specific location. Returns JSON list of main sections that have subsections for given location. Public endpoint (no authentication required). """ from apps.organizations.models import LegacyMainSection, LegacySubSection # Get available sections that have subsections for this location available_section_ids = ( LegacySubSection.objects.filter(location_id=location_id).values_list("main_section_id", flat=True).distinct() ) sections = LegacyMainSection.objects.filter(id__in=available_section_ids).order_by("name_en") sections_list = [ { "id": section.id, "name": str(section), # Uses __str__ which prefers English name } for section in sections ] return JsonResponse( {"success": True, "location_id": location_id, "sections": sections_list, "count": len(sections_list)} ) @require_GET def api_subsections(request, location_id, section_id): """ API endpoint to get subsections for a specific location and section. Returns JSON list of subsections for given location and section. Public endpoint (no authentication required). """ from apps.organizations.models import LegacySubSection subsections = LegacySubSection.objects.filter(location_id=location_id, main_section_id=section_id).order_by("name_en") subsections_list = [ { "id": sub.internal_id, # SubSection uses internal_id as primary key "name": str(sub), # Uses __str__ which prefers English name } for sub in subsections ] return JsonResponse( { "success": True, "location_id": location_id, "section_id": section_id, "subsections": subsections_list, "count": len(subsections_list), } ) @require_GET def api_departments(request, hospital_id): """ API endpoint to get departments for a specific hospital. Returns JSON list of departments for given hospital. Public endpoint (no authentication required). """ from apps.organizations.models import Department departments = Department.objects.filter(hospital_id=hospital_id, status="active").order_by("name") departments_list = [ { "id": dept.id, "name": dept.name, # Department model has 'name' field, not name_en } for dept in departments ] return JsonResponse( {"success": True, "hospital_id": hospital_id, "departments": departments_list, "count": len(departments_list)} ) def complaint_explanation_pdf(request, complaint_id, token): """Token-based PDF download for the explanation page (no login required).""" from .models import ComplaintExplanation complaint = get_object_or_404(Complaint, id=complaint_id) explanation = get_object_or_404(ComplaintExplanation, complaint=complaint, token=token) import io import base64 from PIL import Image as PILImage from django.conf import settings from django.template.loader import render_to_string from weasyprint import HTML logo_path = None try: logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png") logo_img.thumbnail((600, 600), PILImage.LANCZOS) buf = io.BytesIO() logo_img.save(buf, format="PNG", optimize=True) logo_path = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() except Exception: pass staff_name = explanation.staff.get_full_name() if explanation.staff else "" staff_title = explanation.staff.job_title if explanation.staff else "" dept_name = "" if explanation.staff and explanation.staff.department: dept_name = ( explanation.staff.department.name_ar or explanation.staff.department.name_en or explanation.staff.department.name ) from datetime import datetime html_string = render_to_string( "complaints/complaint_explanation_pdf.html", { "complaint": complaint, "logo_path": logo_path, "staff_name": staff_name, "staff_title": staff_title, "department_name": dept_name, "sent_date": (explanation.email_sent_at or complaint.created_at).strftime("%Y/%m/%d %I:%M %p") if (explanation.email_sent_at or complaint.created_at) else "—", }, ) pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf() from django.http import HttpResponse response = HttpResponse(pdf_file, content_type="application/pdf") response["Content-Disposition"] = f'attachment; filename="complaint_{complaint.reference_number}.pdf"' return response def complaint_review_pdf(request, complaint_id, token): """Token-based PDF download for the review page — shows staff Q&A (no login required).""" from .models import ComplaintExplanation, ChampionInvestigation, InvestigationAnswer complaint = get_object_or_404(Complaint, id=complaint_id) explanation = get_object_or_404(ComplaintExplanation, complaint=complaint, token=token) investigation = ChampionInvestigation.objects.filter( explanation=explanation ).prefetch_related( "responses__staff", "responses__answers__question" ).first() if not investigation: from django.http import HttpResponseNotFound return HttpResponseNotFound("No investigation found.") import io import base64 from PIL import Image as PILImage from django.conf import settings from django.template.loader import render_to_string from weasyprint import HTML logo_path = None try: logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png") logo_img.thumbnail((600, 600), PILImage.LANCZOS) buf = io.BytesIO() logo_img.save(buf, format="PNG", optimize=True) logo_path = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() except Exception: pass staff_data = [] for resp in investigation.responses.all().select_related("staff"): qa_pairs = [] for ans in resp.answers.all().select_related("question"): qa_pairs.append({ "question": ans.question.question_text, "question_type": ans.question.question_type, "answer": ans.answer_text, }) staff_data.append({ "staff": resp.staff, "is_completed": resp.is_completed, "completed_at": resp.completed_at, "qa_pairs": qa_pairs, }) html_string = render_to_string("complaints/complaint_review_pdf.html", { "complaint": complaint, "logo_path": logo_path, "investigation": investigation, "staff_data": staff_data, }) pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf() from django.http import HttpResponse response = HttpResponse(pdf_file, content_type="application/pdf") response["Content-Disposition"] = f'attachment; filename="review_{complaint.reference_number}.pdf"' return response def _hospital_departments(complaint): """Active departments in the complaint's hospital (for the suggest-dept dropdown).""" from apps.organizations.models import Department from django.db.models import Q if not complaint.hospital: return Department.objects.none() return Department.objects.filter( hospital=complaint.hospital, status="active", ).filter(Q(champion__isnull=False) | Q(manager__isnull=False)).order_by("name") def complaint_explanation_form(request, complaint_id, token): """ Public-facing form for staff to submit explanation. This view does NOT require authentication. Validates token and checks if it's still valid (not used). """ from django.utils.translation import gettext as _ from .models import ComplaintExplanation, ExplanationAttachment from apps.notifications.services import NotificationService, get_email_header_html from django.contrib.sites.shortcuts import get_current_site # Get complaint complaint = get_object_or_404(Complaint, id=complaint_id) # Validate token with staff and department prefetch explanation = get_object_or_404( ComplaintExplanation.objects.select_related("staff", "staff__department", "staff__report_to"), complaint=complaint, token=token, ) original_explanation = None # Check if token is already used if explanation.is_used: return render( request, "complaints/explanation_already_submitted.html", {"complaint": complaint, "explanation": explanation}, ) if request.method == "POST": from .models import ( ChampionInvestigation, InvestigationStatus, ) action = request.POST.get("action", "") explanation_text = request.POST.get("explanation", "").strip() consent_checked = request.POST.get("consent") == "on" negligence_finding = request.POST.get("negligence_finding", "").strip() policy_issue_finding = request.POST.get("policy_issue_finding", "").strip() requires_improvement_project = request.POST.get("requires_improvement_project", "").strip() improvement_project_note = request.POST.get("improvement_project_note", "").strip() # Shared base context for re-rendering the form base_ctx = { "complaint": complaint, "explanation": explanation, "original_explanation": original_explanation, "final_reply": explanation_text, "consent_checked": consent_checked, "negligence_finding": negligence_finding, "policy_issue_finding": policy_issue_finding, "requires_improvement_project": requires_improvement_project, "improvement_project_note": improvement_project_note, "investigate_url": request.build_absolute_uri( reverse("complaints:champion_start_investigation", kwargs={ "complaint_id": complaint.id, "token": explanation.token, }) ), "hospital_departments": _hospital_departments(complaint), } # --- Step 1: Send verification code --- if action == "send_code": if not explanation_text: return render(request, "complaints/explanation_form.html", { **base_ctx, "error": _("Please write your response first."), }) if not consent_checked: return render(request, "complaints/explanation_form.html", { **base_ctx, "error": _("Please check the acknowledgment box."), }) if requires_improvement_project == "yes" and not improvement_project_note: return render(request, "complaints/explanation_form.html", { **base_ctx, "consent_checked": True, "error": _("Please describe the required improvement project."), }) # Get champion/manager contact info actor = explanation.staff phone = "" email = "" if actor: phone = actor.phone or "" email = actor.email or "" if actor.user: phone = phone or (actor.user.phone or "") email = email or (actor.user.email or "") if not phone and not email: return render(request, "complaints/explanation_form.html", { **base_ctx, "consent_checked": True, "error": _("No phone or email on file. Please contact the PX team."), }) # Get-or-create a ChampionInvestigation to hold the OTP + assessment fields investigation, _ = ChampionInvestigation.objects.get_or_create( explanation=explanation, complaint=complaint, defaults={ "champion": actor, "status": InvestigationStatus.DIRECT_REPLY_IN_PROGRESS, "final_reply": explanation_text, }, ) investigation.final_reply = explanation_text investigation.status = InvestigationStatus.DIRECT_REPLY_IN_PROGRESS investigation.negligence_finding = negligence_finding investigation.policy_issue_finding = policy_issue_finding investigation.requires_improvement_project = requires_improvement_project investigation.improvement_project_note = improvement_project_note import random code = f"{random.randint(0, 999999):06d}" investigation.otp_code = code investigation.otp_sent_at = timezone.now() investigation.save(update_fields=[ "final_reply", "status", "otp_code", "otp_sent_at", "negligence_finding", "policy_issue_finding", "requires_improvement_project", "improvement_project_note", ]) sent_channels = [] if phone: try: sms_log = NotificationService.send_sms( phone, f"PX360: Your verification code is {code}. Enter this to submit your response for complaint #{complaint.reference_number}.", ) if sms_log and getattr(sms_log, "status", None) == "sent": sent_channels.append("phone") except Exception: pass if email: try: email_log = NotificationService.send_email( email=email, subject=f"Verification Code - Complaint #{complaint.reference_number}", message=f"Your verification code is: {code}\n\nEnter this code to submit your response.", related_object=complaint, ) if email_log and getattr(email_log, "status", None) == "sent": sent_channels.append("email") except Exception: pass # Surface a real delivery failure in production. In DEBUG, always proceed so the flow is testable. if not sent_channels and not settings.DEBUG: return render(request, "complaints/explanation_form.html", { **base_ctx, "consent_checked": True, "error": _("Could not send the verification code. Please check your contact details or contact the PX team."), }) otp_ctx = { **base_ctx, "consent_checked": True, "otp_sent": True, "otp_phone": "phone" in sent_channels, "otp_email": "email" in sent_channels, } if settings.DEBUG: otp_ctx["dev_otp_code"] = code return render(request, "complaints/explanation_form.html", otp_ctx) # --- Cancel OTP: user clicked "Edit response" — re-enable the form --- if action == "cancel_otp": investigation = ChampionInvestigation.objects.filter( explanation=explanation, status=InvestigationStatus.DIRECT_REPLY_IN_PROGRESS, ).first() if investigation: investigation.otp_code = "" investigation.otp_sent_at = None investigation.save(update_fields=["otp_code", "otp_sent_at"]) # Re-render with otp_sent=False; base_ctx preserves all form values return render(request, "complaints/explanation_form.html", {**base_ctx}) # --- Step 2: Verify code + submit --- if action == "verify_submit": entered_code = request.POST.get("otp_code", "").strip() if not consent_checked: return render(request, "complaints/explanation_form.html", { **base_ctx, "otp_sent": True, "error": _("Please check the acknowledgment box."), }) try: investigation = ChampionInvestigation.objects.get( explanation=explanation, status=InvestigationStatus.DIRECT_REPLY_IN_PROGRESS, ) except ChampionInvestigation.DoesNotExist: return render(request, "complaints/explanation_form.html", { **base_ctx, "consent_checked": True, "error": _("No verification code was sent. Please request a new code."), }) if not investigation.otp_code or not investigation.otp_sent_at: return render(request, "complaints/explanation_form.html", { **base_ctx, "consent_checked": True, "error": _("No verification code was sent. Please request a new code."), }) from datetime import timedelta expiry = investigation.otp_sent_at + timedelta(minutes=10) if timezone.now() > expiry: return render(request, "complaints/explanation_form.html", { **base_ctx, "consent_checked": True, "error": _("Verification code expired. Please request a new code."), }) if entered_code != investigation.otp_code: return render(request, "complaints/explanation_form.html", { **base_ctx, "consent_checked": True, "otp_sent": True, "error": _("Incorrect verification code. Please try again."), }) # --- Code verified — finalize the submission --- investigation.final_reply = explanation_text investigation.status = InvestigationStatus.REPLY_SUBMITTED investigation.otp_code = "" investigation.negligence_finding = negligence_finding investigation.policy_issue_finding = policy_issue_finding investigation.requires_improvement_project = requires_improvement_project investigation.improvement_project_note = improvement_project_note investigation.save(update_fields=[ "final_reply", "status", "otp_code", "negligence_finding", "policy_issue_finding", "requires_improvement_project", "improvement_project_note", ]) explanation.explanation = explanation_text explanation.is_used = True explanation.responded_at = timezone.now() explanation.save(update_fields=["explanation", "is_used", "responded_at"]) # Save attachments files = request.FILES.getlist("attachments") for uploaded_file in files: ExplanationAttachment.objects.create( explanation=explanation, file=uploaded_file, filename=uploaded_file.name, file_type=uploaded_file.content_type, file_size=uploaded_file.size, ) # First-responder-wins WITHIN the same department only. # Other departments' explanations stay valid (parallel collection). if explanation.staff and explanation.staff.department_id: ComplaintExplanation.objects.filter( complaint=complaint, is_used=False, staff__department_id=explanation.staff.department_id, ).exclude(pk=explanation.pk).update(is_used=True) # Update the linked ComplaintInvolvedDepartment from apps.complaints.models import ComplaintInvolvedDepartment involved_dept = investigation.involved_department if not involved_dept: if explanation.staff and explanation.staff.department: involved_dept = ComplaintInvolvedDepartment.objects.filter( complaint=complaint, department=explanation.staff.department, sent=True, ).first() if not involved_dept: involved_dept = ComplaintInvolvedDepartment.objects.filter( complaint=complaint, sent=True, ).first() if involved_dept: involved_dept.response_notes = explanation_text involved_dept.response_notes_en = explanation_text involved_dept.response_submitted = True involved_dept.response_submitted_at = timezone.now() involved_dept.acceptance_status = "acceptable" involved_dept.accepted_at = timezone.now() involved_dept.save() ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Department response submitted (verified) from {involved_dept.department.name}", metadata={ "explanation_id": str(explanation.id), "staff_id": str(explanation.staff.id) if explanation.staff else None, "investigation_id": str(investigation.id), "flow": "direct_reply", }, ) else: ComplaintUpdate.objects.create( complaint=complaint, update_type="communication", message=f"Explanation submitted by {explanation.staff}", metadata={ "explanation_id": str(explanation.id), "staff_id": str(explanation.staff.id) if explanation.staff else None, }, ) if requires_improvement_project == "yes": ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Improvement project recommended: {improvement_project_note}", metadata={ "investigation_id": str(investigation.id), "flag": "improvement_project", "negligence": negligence_finding, "policy_issue": policy_issue_finding, "note": improvement_project_note, }, ) # Notify PX team if complaint.assigned_to and complaint.assigned_to.email: try: notify_msg = f"A response has been submitted for complaint {complaint.reference_number}." if requires_improvement_project == "yes": notify_msg += f"\n\n*** IMPROVEMENT PROJECT RECOMMENDED ***\n{improvement_project_note}" NotificationService.send_email( email=complaint.assigned_to.email, subject=f"Department Response Received - Complaint #{complaint.reference_number}", message=notify_msg, related_object=complaint, ) except Exception: pass return render( request, "complaints/explanation_success.html", {"complaint": complaint, "explanation": explanation, "attachment_count": len(files)}, ) # --- Reject routing (wrong department) --- if action == "reject_routing": from apps.complaints.services.complaint_service import ( reject_department_routing, RoutingRejectionError, ) from apps.complaints.models import ComplaintInvolvedDepartment from apps.organizations.models import Department reason = request.POST.get("rejection_reason", "").strip() suggested_id = request.POST.get("suggested_department_id", "").strip() suggested = None if suggested_id: suggested = Department.objects.filter(id=suggested_id, hospital=complaint.hospital).first() if not reason: return render(request, "complaints/explanation_form.html", { **base_ctx, "show_reject_form": True, "error": _("Please provide a reason for rejecting this routing."), }) # Resolve the involved department this champion belongs to involved_dept = None if explanation.staff and explanation.staff.department_id: involved_dept = ComplaintInvolvedDepartment.objects.filter( complaint=complaint, department_id=explanation.staff.department_id, ).first() if not involved_dept: involved_dept = ComplaintInvolvedDepartment.objects.filter( complaint=complaint, sent=True, ).first() if not involved_dept: return render(request, "complaints/explanation_form.html", { **base_ctx, "show_reject_form": True, "error": _("Could not find the department routing to reject."), }) try: reject_department_routing( involved_dept, staff=explanation.staff, reason=reason, suggested_department=suggested, ) except RoutingRejectionError as exc: return render(request, "complaints/explanation_form.html", { **base_ctx, "show_reject_form": True, "error": str(exc) if str(exc) else _("This routing can no longer be rejected."), }) # Consume the token so it can't be reused explanation.is_used = True explanation.responded_at = timezone.now() explanation.save(update_fields=["is_used", "responded_at"]) return render( request, "complaints/explanation_routing_rejected.html", {"complaint": complaint, "explanation": explanation, "involved_dept": involved_dept}, ) # Unknown / missing action — show form with error return render(request, "complaints/explanation_form.html", { **base_ctx, "error": _("Invalid request."), }) # GET request - display form from apps.complaints.models import ComplaintInvolvedStaff accused_staff = list( ComplaintInvolvedStaff.objects.filter( complaint=complaint, role=ComplaintInvolvedStaff.RoleChoices.ACCUSED ).select_related("staff") ) existing_investigation = None if explanation.is_used: from apps.complaints.models import ChampionInvestigation existing_investigation = ChampionInvestigation.objects.filter( explanation=explanation ).select_related("champion").first() # Auto-show OTP entry if a direct-reply OTP is still valid (e.g., user reloaded page) otp_sent_on_get = False draft_ctx = {} if not explanation.is_used: from apps.complaints.models import ChampionInvestigation, InvestigationStatus from datetime import timedelta existing = ChampionInvestigation.objects.filter( explanation=explanation, status=InvestigationStatus.DIRECT_REPLY_IN_PROGRESS, otp_code__isnull=False, ).exclude(otp_code="").first() if existing and existing.otp_sent_at and timezone.now() <= existing.otp_sent_at + timedelta(minutes=10): otp_sent_on_get = True draft_ctx = { "final_reply": existing.final_reply or "", "negligence_finding": existing.negligence_finding or "", "policy_issue_finding": existing.policy_issue_finding or "", "requires_improvement_project": existing.requires_improvement_project or "", "improvement_project_note": existing.improvement_project_note or "", "consent_checked": True, } return render( request, "complaints/explanation_form.html", { "complaint": complaint, "explanation": explanation, "original_explanation": original_explanation, "accused_staff": accused_staff, "existing_investigation": existing_investigation, "otp_sent": otp_sent_on_get, "hospital_departments": _hospital_departments(complaint), "investigate_url": request.build_absolute_uri( reverse("complaints:champion_start_investigation", kwargs={ "complaint_id": complaint.id, "token": explanation.token, }) ), **draft_ctx, }, ) def champion_start_investigation(request, complaint_id, token): from .models import ( ComplaintExplanation, ComplaintInvolvedStaff, ChampionInvestigation, InvestigationQuestion, InvestigationResponse, InvestigationAnswer, ComplaintUpdate, ) from apps.notifications.services import NotificationService, get_email_header_html complaint = get_object_or_404(Complaint, id=complaint_id) explanation = get_object_or_404( ComplaintExplanation.objects.select_related("staff", "staff__department"), complaint=complaint, token=token, ) # Check if there's an investigation still in progress in_progress = ChampionInvestigation.objects.filter( explanation=explanation, status__in=["questions_sent", "answers_received"], ).first() if in_progress: # An investigation is active — check if the explanation was marked used # (shouldn't be, but reset just in case so the champion can access it) if explanation.is_used: explanation.is_used = False explanation.save(update_fields=["is_used"]) return render(request, "complaints/investigation_already_started.html", { "complaint": complaint, "investigation": in_progress, }) # If the explanation was used (by a direct reply or a completed investigation round), # but all investigations are complete (reply_submitted), allow starting a new round. if explanation.is_used: # Was it used by a completed investigation? If so, reset for a new round. completed_investigations = ChampionInvestigation.objects.filter( explanation=explanation, status="reply_submitted", ) if completed_investigations.exists(): explanation.is_used = False explanation.save(update_fields=["is_used"]) else: # Used by a direct reply (not investigation) — stay submitted return render(request, "complaints/explanation_already_submitted.html", { "complaint": complaint, "explanation": explanation, }) accused_staff = list( ComplaintInvolvedStaff.objects.filter( complaint=complaint, role=ComplaintInvolvedStaff.RoleChoices.ACCUSED ).select_related("staff") ) # Note: we no longer fall back to showing same-department staff when # ComplaintInvolvedStaff is empty. The champion must explicitly search # and add staff via the token-authenticated search endpoint below. search_url = request.build_absolute_uri( reverse("complaints:staff_search_for_investigation", kwargs={ "complaint_id": complaint.id, "token": explanation.token, }) ) if request.method == "POST": import secrets selected_staff_ids = [sid for sid in request.POST.getlist("accused_staff[]") if sid] # Per-staff questions: field name questions__[] -> list of (text, type) pairs per_staff_questions = {} for sid in selected_staff_ids: qs = [q.strip() for q in request.POST.getlist(f"questions__{sid}[]") if q.strip()] q_types = request.POST.getlist(f"question_types__{sid}[]") if qs: per_staff_questions[sid] = [ (qs[i], q_types[i] if i < len(q_types) else "text") for i in range(len(qs)) ] if not selected_staff_ids: return render(request, "complaints/investigation_questions.html", { "complaint": complaint, "explanation": explanation, "accused_staff": accused_staff, "search_url": search_url, "error": "Please select at least one staff member.", }) if not per_staff_questions: return render(request, "complaints/investigation_questions.html", { "complaint": complaint, "explanation": explanation, "accused_staff": accused_staff, "search_url": search_url, "error": "Please add at least one question for each selected staff member.", }) involved_dept = explanation.linked_involved_department investigation = ChampionInvestigation.objects.create( complaint=complaint, champion=explanation.staff, involved_department=involved_dept, explanation=explanation, status="questions_sent", ) # Champion context attachments (shown to staff when they respond) from .models import InvestigationAttachment for f in request.FILES.getlist("context_attachments"): InvestigationAttachment.objects.create( investigation=investigation, file=f, filename=f.name, file_type=f.content_type, file_size=f.size, uploaded_by=request.user if request.user.is_authenticated else None, ) domain = request.get_host() staff_count = 0 for sid in selected_staff_ids: qs = per_staff_questions.get(sid) or [] if not qs: continue staff_qs = accused_staff matched = next((s for s in staff_qs if str(s.staff_id) == sid), None) if not matched: try: from apps.organizations.models import Staff staff_obj = Staff.objects.get(id=sid) matched = type('obj', (), {'staff': staff_obj, 'staff_id': staff_obj.id})() except Exception: continue staff_member = matched.staff if hasattr(matched, 'staff') else matched # Persist selected staff to ComplaintInvolvedStaff so they appear on # the complaint detail page's involved-staff list going forward. # Idempotent via get_or_create — no duplicates if already involved. ComplaintInvolvedStaff.objects.get_or_create( complaint=complaint, staff=staff_member, defaults={ "role": ComplaintInvolvedStaff.RoleChoices.ACCUSED, "notes": "Added during investigation by champion/manager", }, ) resp_token = secrets.token_urlsafe(32) inv_response = InvestigationResponse.objects.create( investigation=investigation, staff=staff_member, token=resp_token, ) # Create THIS staff member's own questions on their response (per-staff) for i, (q_text, q_type) in enumerate(qs): q = InvestigationQuestion.objects.create( investigation=investigation, response=inv_response, question_text=q_text, question_type=q_type, order=i + 1, ) InvestigationAnswer.objects.get_or_create( response=inv_response, question=q, defaults={"answer_text": ""} ) respond_url = f"https://{domain}/complaints/{complaint.id}/investigate/respond/{resp_token}/" staff_user = staff_member.user if hasattr(staff_member, 'user') and staff_member.user else None staff_email = staff_member.email or (staff_user.email if staff_user else None) # DEV: print the no-login feedback link so it's visible in the console (debug only) from django.conf import settings as _settings if _settings.DEBUG: print(f"[investigation link] {staff_member.get_full_name()} ({staff_email or 'no email'}) -> {respond_url}") if staff_email: try: NotificationService.send_email( email=staff_email, subject=f"Feedback Request - Complaint #{complaint.reference_number}", message=( f"Dear {staff_member.get_full_name()},\n\n" f"You have been requested to provide feedback " f"for complaint #{complaint.reference_number}.\n\n" f"Please respond at: {respond_url}\n\n" f"Note: This link can only be used once." ), html_message=f"""
{get_email_header_html()}

Feedback Request

Dear {staff_member.get_full_name()},

You have been requested to provide feedback for complaint #{complaint.reference_number}.

The department champion would like your response before proceeding.

Provide Feedback

Note: This link can only be used once.

""", related_object=complaint, user=staff_user, ) inv_response.email_sent_at = timezone.now() inv_response.save(update_fields=["email_sent_at"]) except Exception as e: import logging logging.getLogger(__name__).error(f"Failed to send investigation email to {staff_email}: {e}") elif staff_user: from apps.notifications.models import UserNotification UserNotification.objects.create( user=staff_user, title=f"Investigation Questions - Complaint #{complaint.reference_number}", message=f"You have investigation questions to answer for complaint #{complaint.reference_number}.", notification_type="system", content_object=complaint, ) staff_phone = staff_member.phone or (staff_member.user.phone if hasattr(staff_member, 'user') and staff_member.user else None) if staff_phone: try: NotificationService.send_sms( staff_phone, f"You have feedback to provide for complaint #{complaint.reference_number}. Please respond at: {respond_url}", ) inv_response.sms_sent_at = timezone.now() inv_response.save(update_fields=["sms_sent_at"]) except Exception as e: import logging logging.getLogger(__name__).error(f"Failed to send investigation SMS to {staff_phone}: {e}") staff_count += 1 ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Champion {explanation.staff.get_full_name()} started investigation — questions sent to {staff_count} staff member(s)", metadata={ "investigation_id": str(investigation.id), "staff_count": staff_count, }, ) return render(request, "complaints/investigation_questions.html", { "complaint": complaint, "explanation": explanation, "accused_staff": accused_staff, "search_url": search_url, "success": f"Investigation started. Questions sent to {staff_count} staff member(s).", }) return render(request, "complaints/investigation_questions.html", { "complaint": complaint, "explanation": explanation, "accused_staff": accused_staff, "search_url": search_url, }) def staff_search_for_investigation(request, complaint_id, token): """ Public (token-authenticated) staff search for the investigation compose page. GET /complaints//investigate//search-staff/?q= Returns active staff in the complaint's hospital matching name/employee_id. Marks staff already linked to the complaint via ComplaintInvolvedStaff so the JS can render them as already-added (greyed out). """ from django.db.models import Q from django.http import JsonResponse complaint = get_object_or_404(Complaint, id=complaint_id) explanation = get_object_or_404( ComplaintExplanation, complaint=complaint, token=token ) if explanation.is_used: return JsonResponse({"results": [], "error": "token expired"}, status=400) q = request.GET.get("q", "").strip() if len(q) < 2: return JsonResponse({"results": []}) from apps.organizations.models import Staff from .models import ComplaintInvolvedStaff already_linked = set( ComplaintInvolvedStaff.objects.filter(complaint=complaint) .values_list("staff_id", flat=True) ) qs = ( Staff.objects.filter(hospital=complaint.hospital, status="active") .filter( Q(first_name__icontains=q) | Q(last_name__icontains=q) | Q(name__icontains=q) | Q(name_ar__icontains=q) | Q(employee_id__icontains=q) | Q(civil_id__icontains=q) | Q(license_number__icontains=q) ) .select_related("department")[:20] ) results = [ { "id": str(s.id), "name": s.get_full_name(), "department_name": s.department.get_localized_name() if s.department else "", "employee_id": s.employee_id or "", "already_added": s.id in already_linked, } for s in qs ] return JsonResponse({"results": results}) def staff_investigation_form(request, complaint_id, token): from .models import ( InvestigationResponse, InvestigationAnswer, ChampionInvestigation, ComplaintUpdate, ) from apps.notifications.services import NotificationService, get_email_header_html complaint = get_object_or_404(Complaint, id=complaint_id) inv_response = get_object_or_404( InvestigationResponse.objects.select_related( "investigation", "investigation__champion", "staff" ).prefetch_related("investigation__questions"), token=token, ) if inv_response.investigation.complaint_id != complaint.id: return render(request, "complaints/investigation_error.html", { "error": "Invalid link.", }) if inv_response.is_completed: return render(request, "complaints/investigation_already_submitted.html", { "complaint": complaint, "inv_response": inv_response, }) # Per-staff questions: prefer questions tied to this response; fall back to shared questions questions = list(inv_response.questions.all().order_by("order")) if not questions: questions = list(inv_response.investigation.questions.all().order_by("order")) if request.method == "POST": if request.POST.get("consent") != "on": question_answer_pairs = [] for q in questions: question_answer_pairs.append({ "question": q, "existing_answer": "", }) return render(request, "complaints/investigation_respond.html", { "complaint": complaint, "inv_response": inv_response, "question_answer_pairs": question_answer_pairs, "error": _("Please check the acknowledgment box before submitting."), }) for q in questions: answer_text = request.POST.get(f"question_{q.id}", "").strip() InvestigationAnswer.objects.update_or_create( response=inv_response, question=q, defaults={"answer_text": answer_text}, ) inv_response.is_completed = True inv_response.completed_at = timezone.now() inv_response.save(update_fields=["is_completed", "completed_at"]) # Staff attachments from .models import InvestigationResponseAttachment for f in request.FILES.getlist("attachments"): InvestigationResponseAttachment.objects.create( response=inv_response, file=f, filename=f.name, file_type=f.content_type, file_size=f.size, ) investigation = inv_response.investigation if investigation.all_responses_received: investigation.status = "answers_received" investigation.save(update_fields=["status"]) champion = investigation.champion champion_user = champion.user if champion and hasattr(champion, 'user') and champion.user else None champion_email = champion.email if champion else None domain = request.get_host() review_url = f"https://{domain}/complaints/{complaint.id}/investigate/review/{investigation.explanation.token}/" if champion_email: try: NotificationService.send_email( email=champion_email, subject=f"All Investigation Responses Received - Complaint #{complaint.reference_number}", message=( f"Dear {champion.get_full_name()},\n\n" f"All accused staff have responded to your investigation questions " f"for complaint #{complaint.reference_number}.\n\n" f"Please review and submit your final reply: {review_url}" ), html_message=f"""
{get_email_header_html()}

All Investigation Responses Received

Dear {champion.get_full_name()},

All accused staff have responded to your investigation questions for complaint #{complaint.reference_number}.

Please review and submit your final reply.

""", related_object=complaint, user=champion_user, ) except Exception: import logging logging.getLogger(__name__).error(f"Failed to send investigation review email to {champion_email}") elif champion_user: from apps.notifications.models import UserNotification UserNotification.objects.create( user=champion_user, title=f"All Investigation Responses Received - Complaint #{complaint.reference_number}", message=f"All accused staff have responded. Please review and submit your final reply.", notification_type="system", content_object=complaint, ) ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Investigation response submitted by {inv_response.staff.get_full_name()}", ) return render(request, "complaints/investigation_success.html", { "complaint": complaint, "inv_response": inv_response, }) answers_map = {} for ans in InvestigationAnswer.objects.filter(response=inv_response).select_related("question"): answers_map[str(ans.question_id)] = ans.answer_text question_answer_pairs = [] for q in questions: question_answer_pairs.append({ "question": q, "existing_answer": answers_map.get(str(q.id), ""), }) return render(request, "complaints/investigation_respond.html", { "complaint": complaint, "inv_response": inv_response, "question_answer_pairs": question_answer_pairs, "context_attachments": inv_response.investigation.attachments.all(), }) def champion_review_answers(request, complaint_id, token): from .models import ( ComplaintExplanation, ComplaintInvolvedDepartment, ChampionInvestigation, ComplaintUpdate, ) from apps.notifications.services import NotificationService, get_email_header_html complaint = get_object_or_404(Complaint, id=complaint_id) explanation = get_object_or_404( ComplaintExplanation.objects.select_related("staff", "staff__department"), complaint=complaint, token=token, ) investigation = ChampionInvestigation.objects.filter( explanation=explanation ).prefetch_related( "questions", "responses__staff", "responses__answers__question", "responses__attachments", "attachments" ).first() if not investigation: return render(request, "complaints/investigation_error.html", { "error": "No investigation found for this complaint.", }) if investigation.status == InvestigationStatus.REPLY_SUBMITTED: return render(request, "complaints/investigation_already_submitted.html", { "complaint": complaint, "investigation": investigation, }) responses = list(investigation.responses.all().select_related("staff")) # Shared questions (legacy / when not per-staff); each response may override with its own set questions = list(investigation.questions.filter(response__isnull=True).order_by("order")) answer_lookup = {} from .models import InvestigationAnswer for ans in InvestigationAnswer.objects.filter( response__investigation=investigation ).select_related("question"): key = (str(ans.response_id), str(ans.question_id)) answer_lookup[key] = ans.answer_text staff_data = [] for resp in responses: # Per-staff questions: prefer this response's own questions; fall back to shared resp_questions = list(resp.questions.all().order_by("order")) or questions qa_pairs = [] for q in resp_questions: key = (str(resp.id), str(q.id)) qa_pairs.append({ "question": q, "answer": answer_lookup.get(key, ""), }) staff_data.append({ "response": resp, "staff": resp.staff, "is_completed": resp.is_completed, "qa_pairs": qa_pairs, }) base_ctx = { "complaint": complaint, "explanation": explanation, "investigation": investigation, "questions": questions, "staff_data": staff_data, } if request.method == "POST": action = request.POST.get("action", "") final_reply = request.POST.get("final_reply", "").strip() consent_checked = request.POST.get("consent") == "on" negligence_finding = request.POST.get("negligence_finding", "").strip() policy_issue_finding = request.POST.get("policy_issue_finding", "").strip() requires_improvement_project = request.POST.get("requires_improvement_project", "").strip() improvement_project_note = request.POST.get("improvement_project_note", "").strip() # --- Step 1: Send verification code --- if action == "send_code": if not final_reply: return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "error": "Please write your final reply first.", }) if not consent_checked: return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "error": _("Please check the acknowledgment box."), }) if requires_improvement_project == "yes" and not improvement_project_note: return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "consent_checked": True, "error": _("Please describe the required improvement project."), }) # Verification code goes to whoever is actually reviewing. Prefer the # logged-in reviewer (champ or manager); fall back to the token owner # (explanation.staff) for anonymous / token-only access. phone = "" email = "" reviewer = request.user if getattr(request.user, "is_authenticated", False) else None if reviewer: email = reviewer.email or "" phone = getattr(reviewer, "phone", "") or "" sp = getattr(reviewer, "staff_profile", None) if sp: email = email or sp.email or "" phone = phone or sp.phone or "" # Fallback to the token owner (investigator of record) if not email and not phone: token_owner = explanation.staff if token_owner: phone = token_owner.phone or "" email = token_owner.email or "" if token_owner.user: phone = phone or (token_owner.user.phone or "") email = email or (token_owner.user.email or "") if not phone and not email: return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "consent_checked": True, "error": "No phone or email on file. Please contact the PX team.", }) import random code = f"{random.randint(0, 999999):06d}" investigation.otp_code = code investigation.otp_sent_at = timezone.now() # Persist the assessment draft so it survives a page reload / return visit # (the OTP step locks the form; without persisting, the data is lost on GET). investigation.final_reply = final_reply investigation.negligence_finding = negligence_finding investigation.policy_issue_finding = policy_issue_finding investigation.requires_improvement_project = requires_improvement_project investigation.improvement_project_note = improvement_project_note investigation.save(update_fields=[ "otp_code", "otp_sent_at", "final_reply", "negligence_finding", "policy_issue_finding", "requires_improvement_project", "improvement_project_note", ]) sent_channels = [] if phone: try: sms_log = NotificationService.send_sms( phone, f"PX360: Your verification code is {code}. Enter this to submit your response for complaint #{complaint.reference_number}.", ) if sms_log and getattr(sms_log, "status", None) == "sent": sent_channels.append("phone") except Exception: pass if email: try: email_log = NotificationService.send_email( email=email, subject=f"Verification Code - Complaint #{complaint.reference_number}", message=f"Your verification code is: {code}\n\nEnter this code to submit your response.", related_object=complaint, ) if email_log and getattr(email_log, "status", None) == "sent": sent_channels.append("email") except Exception: pass # Surface a real delivery failure in production. In DEBUG, always proceed so the flow is testable. if not sent_channels and not settings.DEBUG: return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "consent_checked": True, "error": _("Could not send the verification code. Please check your contact details or contact the PX team."), }) otp_ctx = { **base_ctx, "final_reply": final_reply, "consent_checked": True, "otp_sent": True, "otp_phone": "phone" in sent_channels, "otp_email": "email" in sent_channels, } if settings.DEBUG: otp_ctx["dev_otp_code"] = code return render(request, "complaints/investigation_review.html", otp_ctx) # --- Cancel OTP: user clicked "Edit response" — re-enable the form --- if action == "cancel_otp": investigation.otp_code = "" investigation.otp_sent_at = None investigation.save(update_fields=["otp_code", "otp_sent_at"]) return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "consent_checked": True, }) # --- Step 2: Verify code + submit --- if action == "verify_submit": entered_code = request.POST.get("otp_code", "").strip() if not consent_checked: return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "otp_sent": True, "error": _("Please check the acknowledgment box."), }) # Validate code if not investigation.otp_code or not investigation.otp_sent_at: return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "consent_checked": True, "error": "No verification code was sent. Please request a new code.", }) # Check expiry (10 minutes) from datetime import timedelta expiry = investigation.otp_sent_at + timedelta(minutes=10) if timezone.now() > expiry: return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "consent_checked": True, "error": "Verification code expired. Please request a new code.", }) if entered_code != investigation.otp_code: return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "consent_checked": True, "otp_sent": True, "error": "Incorrect verification code. Please try again.", }) # --- Code verified — process the submission --- investigation.final_reply = final_reply investigation.status = InvestigationStatus.REPLY_SUBMITTED investigation.otp_code = "" investigation.negligence_finding = negligence_finding investigation.policy_issue_finding = policy_issue_finding investigation.requires_improvement_project = requires_improvement_project investigation.improvement_project_note = improvement_project_note investigation.save(update_fields=[ "final_reply", "status", "otp_code", "negligence_finding", "policy_issue_finding", "requires_improvement_project", "improvement_project_note", ]) explanation.explanation = final_reply explanation.is_used = True explanation.responded_at = timezone.now() explanation.save(update_fields=["explanation", "is_used", "responded_at"]) # Save champion's attachments from the review form from .models import ExplanationAttachment for f in request.FILES.getlist("attachments"): ExplanationAttachment.objects.create( explanation=explanation, file=f, filename=f.name, file_type=f.content_type, file_size=f.size, ) # First-responder-wins WITHIN the same department only. # Other departments' explanations stay valid (parallel collection). if explanation.staff and explanation.staff.department_id: ComplaintExplanation.objects.filter( complaint=complaint, is_used=False, staff__department_id=explanation.staff.department_id, ).exclude(pk=explanation.pk).update(is_used=True) involved_dept = investigation.involved_department or explanation.linked_involved_department if involved_dept: involved_dept.response_notes = final_reply involved_dept.response_notes_en = final_reply involved_dept.response_submitted = True involved_dept.response_submitted_at = timezone.now() involved_dept.acceptance_status = "acceptable" involved_dept.accepted_at = timezone.now() involved_dept.save() if complaint.assigned_to and complaint.assigned_to.email: try: notify_msg = f"A response has been submitted for complaint {complaint.reference_number}." if requires_improvement_project == "yes": notify_msg += f"\n\n*** IMPROVEMENT PROJECT RECOMMENDED ***\n{improvement_project_note}" NotificationService.send_email( email=complaint.assigned_to.email, subject=f"Department Response Received - Complaint #{complaint.reference_number}", message=notify_msg, related_object=complaint, ) except Exception: pass ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Department response submitted (verified) from {explanation.staff.get_full_name()}", metadata={"investigation_id": str(investigation.id), "response_count": len(responses)}, ) if requires_improvement_project == "yes": ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Improvement project recommended: {improvement_project_note}", metadata={ "investigation_id": str(investigation.id), "flag": "improvement_project", "negligence": negligence_finding, "policy_issue": policy_issue_finding, "note": improvement_project_note, }, ) return render(request, "complaints/explanation_success.html", { "complaint": complaint, "explanation": explanation, "attachment_count": 0, }) # Unknown action return render(request, "complaints/investigation_review.html", { **base_ctx, "final_reply": final_reply, "error": "Invalid action.", }) # GET: show review page (auto-show code entry if OTP was previously sent and still valid) from datetime import timedelta show_otp = bool( investigation.otp_code and investigation.otp_sent_at and timezone.now() <= investigation.otp_sent_at + timedelta(minutes=10) ) # Restore the persisted assessment draft (so a return visit keeps the data the # reviewer already entered before requesting the code). Consent was already # captured when the code was sent, so pre-check it while the OTP is pending. return render(request, "complaints/investigation_review.html", { **base_ctx, "otp_sent": show_otp, "final_reply": investigation.final_reply, "negligence_finding": investigation.negligence_finding, "policy_issue_finding": investigation.policy_issue_finding, "requires_improvement_project": investigation.requires_improvement_project, "improvement_project_note": investigation.improvement_project_note, "consent_checked": show_otp, }) from django.http import HttpResponse from django.utils.translation import gettext as _ def generate_complaint_pdf(request, pk): """ Generate PDF for a complaint using WeasyPrint. Creates a professionally styled PDF document with all complaint details including AI analysis, staff assignment, and resolution information. """ # Get complaint complaint = get_object_or_404(Complaint, id=pk) # Check permissions user = request.user if not user.is_authenticated: return HttpResponse("Unauthorized", status=401) # Check if user can view this complaint can_view = False if user.is_px_admin(): can_view = True elif user.is_hospital_admin() and user.hospital == complaint.hospital: can_view = True elif user.is_department_manager() and user.department == complaint.department: can_view = True elif user.hospital == complaint.hospital: can_view = True if not can_view: return HttpResponse("Forbidden", status=403) # Render HTML template with comprehensive data from django.template.loader import render_to_string # Load logo for the letterhead import io import base64 from PIL import Image as PILImage logo_path = None try: logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png") logo_img.thumbnail((600, 600), PILImage.LANCZOS) _buf = io.BytesIO() logo_img.save(_buf, format="PNG", optimize=True) logo_path = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode() except Exception: pass # Get explanations with their acceptance status explanations = complaint.explanations.all().select_related("staff", "accepted_by").prefetch_related("attachments") # Get timeline updates timeline = complaint.updates.all().select_related("created_by")[:20] # Limit to last 20 # Get related PX Actions from apps.px_action_center.models import PXAction from django.contrib.contenttypes.models import ContentType complaint_ct = ContentType.objects.get_for_model(Complaint) px_actions = PXAction.objects.filter(content_type=complaint_ct, object_id=complaint.id).order_by("-created_at")[:5] html_string = render_to_string( "complaints/complaint_pdf.html", { "complaint": complaint, "explanations": explanations, "timeline": timeline, "px_actions": px_actions, "generated_at": timezone.now(), "logo_path": logo_path, }, ) # Generate PDF using WeasyPrint try: from weasyprint import HTML pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf() # Create response response = HttpResponse(pdf_file, content_type="application/pdf") # Allow PDF to be displayed in iframe (same origin only) response["X-Frame-Options"] = "SAMEORIGIN" # Check if view=inline is requested (for iframe display) view_mode = request.GET.get("view", "download") if view_mode == "inline": # Display inline in browser response["Content-Disposition"] = "inline" else: # Download as attachment from datetime import datetime timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"complaint_{complaint.reference_number}_{timestamp}.pdf" response["Content-Disposition"] = f'attachment; filename="{filename}"' # Log audit AuditService.log_from_request( event_type="pdf_generated", description=f"PDF generated for complaint: {complaint.title}", request=request, content_object=complaint, metadata={"complaint_id": str(pk)}, ) return response except ImportError: return HttpResponse("WeasyPrint is not installed. Please install it to generate PDFs.", status=500) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Error generating PDF for complaint {pk}: {e}") return HttpResponse(f"Error generating PDF: {str(e)}", status=500) def generate_complaint_pdf_v2(request, pk): """Generate complaint PDF using the Artboard 1 PNG letterhead (v2).""" complaint = get_object_or_404(Complaint, id=pk) if not request.user.is_authenticated: return HttpResponse("Unauthorized", status=401) if not ( request.user.is_px_admin() or (request.user.is_hospital_admin() and request.user.hospital == complaint.hospital) or (request.user.is_department_manager() and request.user.department == complaint.department) or (request.user.hospital == complaint.hospital) ): return HttpResponse("Forbidden", status=403) from django.template.loader import render_to_string import io import base64 from PIL import Image as PILImage logo_path = None try: logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png") logo_img.thumbnail((600, 600), PILImage.LANCZOS) _buf = io.BytesIO() logo_img.save(_buf, format="PNG", optimize=True) logo_path = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode() except Exception: pass def _img_to_data_uri(path): """Open an image file and return it as a base64 data URI.""" try: img = PILImage.open(path) buf = io.BytesIO() img.save(buf, format="PNG", optimize=True) return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() except Exception: return None # Letterhead: hospital-specific upload → static fallback letterhead_path = None if complaint.hospital and complaint.hospital.letterhead: letterhead_path = _img_to_data_uri(complaint.hospital.letterhead.path) if not letterhead_path: letterhead_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "artboard" / "Artboard 1@3x.png") # Stamp: hospital-specific upload → static fallback stamp_path = None if complaint.hospital and complaint.hospital.stamp: stamp_path = _img_to_data_uri(complaint.hospital.stamp.path) if not stamp_path: stamp_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "stamps" / "stamp.png") explanations = complaint.explanations.all().select_related("staff", "requested_by").prefetch_related("attachments") timeline = complaint.updates.all().select_related("created_by")[:20] from apps.px_action_center.models import PXAction from django.contrib.contenttypes.models import ContentType complaint_ct = ContentType.objects.get_for_model(Complaint) px_actions = PXAction.objects.filter(content_type=complaint_ct, object_id=complaint.id).order_by("-created_at")[:5] # QR code encoding the public tracking URL for authenticity verification qr_code_path = None try: import qrcode from django.urls import reverse tracking_url = request.build_absolute_uri( reverse("complaints:public_complaint_track") ) + "?reference=" + complaint.reference_number qr = qrcode.QRCode(version=1, box_size=4, border=1) qr.add_data(tracking_url) qr.make(fit=True) qr_img = qr.make_image(fill_color="black", back_color="white") qr_buf = io.BytesIO() qr_img.save(qr_buf, format="PNG") qr_code_path = "data:image/png;base64," + base64.b64encode(qr_buf.getvalue()).decode() except Exception: pass html_string = render_to_string( "complaints/complaint_pdf_v2.html", { "complaint": complaint, "explanations": explanations, "timeline": timeline, "px_actions": px_actions, "generated_at": timezone.now(), "logo_path": logo_path, "letterhead_path": letterhead_path, "stamp_path": stamp_path, "qr_code_path": qr_code_path, }, ) try: from weasyprint import HTML pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf() response = HttpResponse(pdf_file, content_type="application/pdf") response["X-Frame-Options"] = "SAMEORIGIN" view_mode = request.GET.get("view", "download") if view_mode == "inline": response["Content-Disposition"] = "inline" else: from datetime import datetime timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"complaint_{complaint.reference_number}_{timestamp}.pdf" response["Content-Disposition"] = f'attachment; filename="{filename}"' return response except ImportError: return HttpResponse("WeasyPrint is not installed.", status=500) except Exception as e: import logging logging.getLogger(__name__).error(f"Error generating v2 PDF for complaint {pk}: {e}") return HttpResponse(f"Error generating PDF: {str(e)}", status=500) def inquiry_explanation_form(request, inquiry_id, token): """ Public-facing form for staff to submit response to an inquiry. Does NOT require authentication. Validates token and checks validity. """ from .models import InquiryExplanation, InquiryExplanationAttachment inquiry = get_object_or_404(Inquiry, id=inquiry_id) explanation = get_object_or_404( InquiryExplanation.objects.select_related("staff", "staff__department"), inquiry=inquiry, token=token, ) if explanation.is_used: return render( request, "complaints/inquiry_explanation_already_submitted.html", {"inquiry": inquiry, "explanation": explanation}, ) if request.method == "POST": explanation_text = request.POST.get("explanation", "").strip() if not explanation_text: return render( request, "complaints/inquiry_explanation_form.html", { "inquiry": inquiry, "explanation": explanation, "error": "Please provide your response.", }, ) explanation.explanation = explanation_text explanation.is_used = True explanation.responded_at = timezone.now() explanation.save() files = request.FILES.getlist("attachments") for uploaded_file in files: InquiryExplanationAttachment.objects.create( explanation=explanation, file=uploaded_file, filename=uploaded_file.name, file_type=uploaded_file.content_type, file_size=uploaded_file.size, ) return render( request, "complaints/inquiry_explanation_success.html", {"inquiry": inquiry, "explanation": explanation}, ) return render( request, "complaints/inquiry_explanation_form.html", {"inquiry": inquiry, "explanation": explanation}, ) def inquiry_respond_with_token(request, pk, token): """ Public-facing form for department staff to submit response to an inquiry. Does NOT require authentication. Validates token and checks validity. """ from apps.core.ai_service import AIService from apps.notifications.services import NotificationService from apps.core.utils import build_public_track_url inquiry = get_object_or_404(Inquiry, pk=pk) if inquiry.response_token != token or not inquiry.response_token: return render(request, "complaints/inquiry_response_token_invalid.html", {"inquiry": inquiry}) if inquiry.response_token_used: return render(request, "complaints/inquiry_response_already_submitted.html", {"inquiry": inquiry}) if request.method == "POST": response_en = request.POST.get("response_en", "").strip() response_ar = request.POST.get("response_ar", "").strip() response = response_en or response_ar if not response: return render(request, "complaints/inquiry_response_form_token.html", { "inquiry": inquiry, "error": "Please enter a response in at least one language.", }) inquiry.department_response_en = response_en inquiry.department_response_ar = response_ar inquiry.department_responded_at = timezone.now() inquiry.dept_response_is_overdue = False inquiry.response_token_used = True inquiry.save() # AI summary try: import json prompt = f"""Summarize the following department response to a patient inquiry in 2-3 concise sentences. Inquiry subject: {inquiry.subject} Inquiry message: {(inquiry.message or '')[:500]} Department response: {response[:500]} Generate JSON with "summary_en" and "summary_ar".""" result = AIService.chat_completion(prompt=prompt, response_format="json_object") parsed = json.loads(result) inquiry.department_response_summary_en = parsed.get("summary_en", "") inquiry.department_response_summary_ar = parsed.get("summary_ar", "") inquiry.save(update_fields=["department_response_summary_en", "department_response_summary_ar"]) except Exception: pass return render(request, "complaints/inquiry_response_success_token.html", {"inquiry": inquiry}) return render(request, "complaints/inquiry_response_form_token.html", {"inquiry": inquiry}) @login_required def inquiry_pdf(request, pk): """Generate a PDF for an inquiry.""" from .models import Inquiry from apps.core.pdf_utils import generate_letterhead_pdf obj = get_object_or_404(Inquiry, id=pk) return generate_letterhead_pdf( "complaints/inquiry_pdf.html", {"object": obj}, f"inquiry_{obj.reference_number}.pdf", )