""" Complaints views and viewsets """ from django.db.models import Q from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from apps.core.services import AuditService from .models import ( Complaint, ComplaintAttachment, ComplaintExplanation, ComplaintMeeting, ComplaintPRInteraction, ComplaintUpdate, Inquiry ) from .serializers import ( ComplaintAttachmentSerializer, ComplaintListSerializer, ComplaintMeetingSerializer, ComplaintPRInteractionSerializer, ComplaintSerializer, ComplaintUpdateSerializer, InquirySerializer, ) def map_complaint_category_to_action_category(complaint_category_code): """ Map complaint category code to PX Action category. Provides intelligent mapping from complaint categories to PX Action categories. Returns 'other' as fallback if no match found. """ if not complaint_category_code: return 'other' mapping = { # Clinical issues 'clinical': 'clinical_quality', 'medical': 'clinical_quality', 'diagnosis': 'clinical_quality', 'treatment': 'clinical_quality', 'medication': 'clinical_quality', 'care': 'clinical_quality', # Safety issues 'safety': 'patient_safety', 'risk': 'patient_safety', 'incident': 'patient_safety', 'infection': 'patient_safety', 'harm': 'patient_safety', # Service quality 'service': 'service_quality', 'communication': 'service_quality', 'wait': 'service_quality', 'response': 'service_quality', 'customer_service': 'service_quality', 'timeliness': 'service_quality', 'waiting_time': 'service_quality', # Staff behavior 'staff': 'staff_behavior', 'behavior': 'staff_behavior', 'attitude': 'staff_behavior', 'professionalism': 'staff_behavior', 'rude': 'staff_behavior', 'respect': 'staff_behavior', # Facility 'facility': 'facility', 'environment': 'facility', 'cleanliness': 'facility', 'equipment': 'facility', 'infrastructure': 'facility', 'parking': 'facility', 'accessibility': 'facility', # Process 'process': 'process_improvement', 'administrative': 'process_improvement', 'billing': 'process_improvement', 'procedure': 'process_improvement', 'workflow': 'process_improvement', 'registration': 'process_improvement', 'appointment': 'process_improvement', } # Try exact match first category_lower = complaint_category_code.lower() if category_lower in mapping: return mapping[category_lower] # Try partial match (contains the keyword) for keyword, action_category in mapping.items(): if keyword in category_lower: return action_category # Fallback to 'other' return 'other' class ComplaintViewSet(viewsets.ModelViewSet): """ ViewSet for Complaints with workflow actions. Permissions: - All authenticated users can view complaints - PX Admins and Hospital Admins can create/manage complaints """ queryset = Complaint.objects.all() permission_classes = [IsAuthenticated] filterset_fields = [ 'status', 'severity', 'priority', 'category', 'source', 'hospital', 'department', 'staff', 'assigned_to', 'is_overdue', 'hospital__organization' ] search_fields = ['title', 'description', 'reference_number', 'patient__mrn', 'patient__first_name', 'patient__last_name'] ordering_fields = ['created_at', 'due_at', 'severity'] ordering = ['-created_at'] def get_serializer_class(self): """Use simplified serializer for list view""" if self.action == 'list': return ComplaintListSerializer return ComplaintSerializer def get_queryset(self): """Filter complaints based on user role""" queryset = super().get_queryset().select_related( 'patient', 'hospital', 'department', 'staff', 'assigned_to', 'resolved_by', 'closed_by', 'created_by' ).prefetch_related('attachments', 'updates') user = self.request.user # PX Admins see all complaints if user.is_px_admin(): return queryset # Source Users see ONLY complaints THEY created if hasattr(user, 'source_user_profile') and user.source_user_profile.exists(): return queryset.filter(created_by=user) # Patients see ONLY their own complaints (if they have user accounts) # This assumes patients can have user accounts linked via patient.user if hasattr(user, 'patient_profile'): return queryset.filter(patient__user=user) # Hospital Admins see complaints for their hospital if user.is_hospital_admin() and user.hospital: return queryset.filter(hospital=user.hospital) # Department Managers see complaints for their department if user.is_department_manager() and user.department: return queryset.filter(department=user.department) # Others see complaints for their hospital if user.hospital: return queryset.filter(hospital=user.hospital) return queryset.none() def get_object(self): """ Override get_object to allow PX Admins to access complaints for specific actions (request_explanation, resend_explanation, send_notification, assignable_admins). """ queryset = self.filter_queryset(self.get_queryset()) # PX Admins can access any complaint for specific actions if self.request.user.is_px_admin() and self.action in [ 'request_explanation', 'resend_explanation', 'send_notification', 'assignable_admins', 'escalate_explanation', 'review_explanation' ]: # Bypass queryset filtering and get directly by pk lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field lookup_value = self.kwargs[lookup_url_kwarg] return get_object_or_404(Complaint, pk=lookup_value) # Normal behavior for other users/actions lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} obj = get_object_or_404(queryset, **filter_kwargs) # May raise a permission denied self.check_object_permissions(self.request, obj) return obj def perform_create(self, serializer): """Log complaint creation and trigger resolution satisfaction survey""" # Auto-set created_by from request.user complaint = serializer.save(created_by=self.request.user) AuditService.log_from_request( event_type='complaint_created', description=f"Complaint created: {complaint.title}", request=self.request, content_object=complaint, metadata={ 'category': complaint.category, 'severity': complaint.severity, 'patient_mrn': complaint.patient.mrn, 'created_by': str(complaint.created_by.id) if complaint.created_by else None } ) # Trigger AI analysis (includes PX Action auto-creation if enabled) from apps.complaints.tasks import analyze_complaint_with_ai analyze_complaint_with_ai.delay(str(complaint.id)) @action(detail=True, methods=['post']) def activate(self, request, pk=None): """ Activate complaint by assigning it to current user. Only PX Admins and Hospital Admins can activate complaints. Sets assigned_to to current user, assigned_at to current time, and status to 'in_progress'. """ complaint = self.get_object() # Check if user has permission to activate if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( {'error': 'Only PX Admins and Hospital Admins can activate complaints'}, status=status.HTTP_403_FORBIDDEN ) # Check if already assigned to current user if complaint.assigned_to == request.user: return Response( {'error': 'This complaint is already assigned to you'}, status=status.HTTP_400_BAD_REQUEST ) old_assignee = complaint.assigned_to old_status = complaint.status # Update complaint complaint.assigned_to = request.user complaint.assigned_at = timezone.now() complaint.status = 'in_progress' complaint.save(update_fields=['assigned_to', 'assigned_at', 'status']) # Create update roles_display = ', '.join(request.user.get_role_names()) ComplaintUpdate.objects.create( complaint=complaint, update_type='assignment', message=f"Complaint activated and assigned to {request.user.get_full_name()} ({roles_display})", created_by=request.user, metadata={ 'old_assignee_id': str(old_assignee.id) if old_assignee else None, 'new_assignee_id': str(request.user.id), 'assignee_roles': request.user.get_role_names(), 'old_status': old_status, 'new_status': 'in_progress', 'activated_by_current_user': True } ) # Log audit AuditService.log_from_request( event_type='complaint_activated', description=f"Complaint activated by {request.user.get_full_name()}", request=request, content_object=complaint, metadata={ 'old_assignee_id': str(old_assignee.id) if old_assignee else None, 'new_assignee_id': str(request.user.id), 'old_status': old_status, 'new_status': 'in_progress' } ) 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: user = User.objects.get(id=user_id) # Verify user has appropriate role if not (user.is_px_admin() or user.is_hospital_admin()): return Response( {'error': 'Only PX Admins and Hospital Admins can be assigned to complaints'}, status=status.HTTP_400_BAD_REQUEST ) old_assignee = complaint.assigned_to complaint.assigned_to = user complaint.assigned_at = timezone.now() complaint.save(update_fields=['assigned_to', 'assigned_at']) # Create update roles_display = ', '.join(user.get_role_names()) ComplaintUpdate.objects.create( complaint=complaint, update_type='assignment', message=f"Assigned to {user.get_full_name()} ({roles_display})", created_by=request.user, metadata={ 'old_assignee_id': str(old_assignee.id) if old_assignee else None, 'new_assignee_id': str(user.id), 'assignee_roles': user.get_role_names() } ) AuditService.log_from_request( event_type='assignment', description=f"Complaint assigned to {user.get_full_name()} ({roles_display})", request=request, content_object=complaint, metadata={ 'old_assignee_id': str(old_assignee.id) if old_assignee else None, 'new_assignee_id': str(user.id) } ) return Response({'message': 'Complaint assigned successfully'}) except User.DoesNotExist: return Response( {'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND ) @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() new_status = request.data.get('status') note = request.data.get('note', '') # Resolution-specific fields resolution_text = request.data.get('resolution', '') resolution_category = request.data.get('resolution_category', '') if not new_status: return Response( {'error': 'status is required'}, status=status.HTTP_400_BAD_REQUEST ) old_status = complaint.status complaint.status = new_status # Handle status-specific logic if new_status == 'resolved': complaint.resolved_at = timezone.now() complaint.resolved_by = request.user # Update resolution fields if provided if resolution_text: complaint.resolution = resolution_text if resolution_category: complaint.resolution_category = resolution_category elif new_status == 'closed': complaint.closed_at = timezone.now() complaint.closed_by = request.user # Trigger resolution satisfaction survey from apps.complaints.tasks import send_complaint_resolution_survey send_complaint_resolution_survey.delay(str(complaint.id)) complaint.save() # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type='status_change', message=note or f"Status changed from {old_status} to {new_status}", created_by=request.user, old_status=old_status, new_status=new_status, metadata={ 'resolution_text': resolution_text if resolution_text else None, 'resolution_category': resolution_category if resolution_category else None } ) AuditService.log_from_request( event_type='status_change', description=f"Complaint status changed from {old_status} to {new_status}", request=request, content_object=complaint, metadata={ 'old_status': old_status, 'new_status': new_status, 'resolution_category': resolution_category if resolution_category else None } ) 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') if not note: return Response( {'error': 'note is required'}, status=status.HTTP_400_BAD_REQUEST ) # Create update update = ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=note, created_by=request.user ) 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) except Department.DoesNotExist: return Response( {'error': 'Department not found'}, status=status.HTTP_404_NOT_FOUND ) # Check department belongs to same hospital if department.hospital != complaint.hospital: return Response( {'error': 'Department does not belong to complaint hospital'}, status=status.HTTP_400_BAD_REQUEST ) # Update complaint old_department_id = str(complaint.department.id) if complaint.department else None complaint.department = department complaint.save(update_fields=['department']) # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type='assignment', message=f"Department changed to {department.name}", created_by=request.user, metadata={ 'old_department_id': old_department_id, 'new_department_id': str(department.id) } ) # Log audit AuditService.log_from_request( event_type='department_change', description=f"Complaint department changed to {department.name}", request=request, content_object=complaint, metadata={ 'old_department_id': old_department_id, 'new_department_id': str(department.id) } ) return Response({ 'message': 'Department changed successfully', 'department_id': str(department.id), 'department_name': department.name }) @action(detail=True, methods=['post']) def create_action_from_ai(self, request, pk=None): """Create PX Action using AI service to generate action details from complaint""" complaint = self.get_object() # Use AI service to generate action data from apps.core.ai_service import AIService try: action_data = AIService.create_px_action_from_complaint(complaint) except Exception as e: return Response( {'error': f'Failed to generate action data: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # Get optional assigned_to from request (AI doesn't assign by default) assigned_to_id = request.data.get('assigned_to') assigned_to = None if assigned_to_id: from apps.accounts.models import User try: assigned_to = User.objects.get(id=assigned_to_id) except User.DoesNotExist: return Response( {'error': 'Assigned user not found'}, status=status.HTTP_404_NOT_FOUND ) # Create PX Action from apps.px_action_center.models import PXAction, PXActionLog from django.contrib.contenttypes.models import ContentType complaint_content_type = ContentType.objects.get_for_model(Complaint) action = PXAction.objects.create( source_type='complaint', content_type=complaint_content_type, object_id=complaint.id, title=action_data['title'], description=action_data['description'], hospital=complaint.hospital, department=complaint.department, category=action_data['category'], priority=action_data['priority'], severity=action_data['severity'], assigned_to=assigned_to, status='open', metadata={ 'source_complaint_id': str(complaint.id), 'source_complaint_title': complaint.title, 'ai_generated': True, 'ai_reasoning': action_data.get('reasoning', ''), 'created_from_ai_suggestion': True } ) # Create action log entry PXActionLog.objects.create( action=action, log_type='note', message=f"Action generated by AI for complaint: {complaint.title}", created_by=request.user, metadata={ 'complaint_id': str(complaint.id), 'ai_generated': True, 'category': action_data['category'], 'priority': action_data['priority'], 'severity': action_data['severity'] } ) # Create complaint update ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=f"PX Action created from AI-generated suggestion (Action #{action.id}) - {action_data['category']}", created_by=request.user, metadata={'action_id': str(action.id), 'category': action_data['category']} ) # Log audit AuditService.log_from_request( event_type='action_created_from_ai', description=f"PX Action created from AI analysis for complaint: {complaint.title}", request=request, content_object=action, metadata={ 'complaint_id': str(complaint.id), 'category': action_data['category'], 'priority': action_data['priority'], 'severity': action_data['severity'], 'ai_reasoning': action_data.get('reasoning', '') } ) return Response({ 'action_id': str(action.id), 'message': 'Action created successfully from AI analysis', 'action_data': { 'title': action_data['title'], 'category': action_data['category'], 'priority': action_data['priority'], 'severity': action_data['severity'] } }, status=status.HTTP_201_CREATED) @action(detail=True, methods=['post']) def send_notification(self, request, pk=None): """ Send email notification to staff member or department head. Sends complaint notification with AI-generated summary (editable by user). Logs the operation to NotificationLog and ComplaintUpdate. Recipient Priority: 1. Staff with user account 2. Staff with email field 3. Department manager """ complaint = self.get_object() # Get email message (required) email_message = request.data.get('email_message', '').strip() if not email_message: return Response( {'error': 'email_message is required'}, status=status.HTTP_400_BAD_REQUEST ) # Get additional message (optional) additional_message = request.data.get('additional_message', '').strip() # Determine recipient with priority logic recipient = None recipient_display = None recipient_type = None recipient_email = None # Priority 1: Staff member with user account if complaint.staff and complaint.staff.user: recipient = complaint.staff.user recipient_display = str(complaint.staff) recipient_type = 'Staff Member (User Account)' recipient_email = recipient.email # Priority 2: Staff member with email field (no user account) elif complaint.staff and complaint.staff.email: recipient_display = str(complaint.staff) recipient_type = 'Staff Member (Email)' recipient_email = complaint.staff.email # Priority 3: Department head elif complaint.department and complaint.department.manager: recipient = complaint.department.manager recipient_display = recipient.get_full_name() recipient_type = 'Department Head' recipient_email = recipient.email # Check if we found a recipient with email if not recipient_email: return Response( {'error': 'No valid recipient found. Complaint must have staff with email, or a department manager with email.'}, status=status.HTTP_400_BAD_REQUEST ) # Construct email content subject = f"Complaint Notification - #{complaint.id}" # Build email body email_body = f""" Dear {recipient_display}, You have been assigned to review the following complaint: COMPLAINT DETAILS: ---------------- ID: #{complaint.id} Title: {complaint.title} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} Status: {complaint.get_status_display()} SUMMARY: -------- {email_message} """ # Add patient info if available if complaint.patient: email_body += f""" PATIENT INFORMATION: ------------------ Name: {complaint.patient.get_full_name()} MRN: {complaint.patient.mrn} """ # Add additional message if provided if additional_message: email_body += f""" ADDITIONAL MESSAGE: ------------------ {additional_message} """ # Add link to complaint from django.contrib.sites.shortcuts import get_current_site site = get_current_site(request) complaint_url = f"https://{site.domain}/complaints/{complaint.id}/" email_body += f""" To view the full complaint details, please visit: {complaint_url} Thank you for your attention to this matter. --- This is an automated message from PX360 Complaint Management System. """ # Send email using NotificationService from apps.notifications.services import NotificationService try: notification_log = NotificationService.send_email( email=recipient_email, subject=subject, message=email_body, related_object=complaint, metadata={ 'notification_type': 'complaint_notification', 'recipient_type': recipient_type, 'recipient_id': str(recipient.id) if recipient else None, 'sender_id': str(request.user.id), 'has_additional_message': bool(additional_message) } ) except Exception as e: return Response( {'error': f'Failed to send email: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # Create ComplaintUpdate entry ComplaintUpdate.objects.create( complaint=complaint, update_type='communication', message=f"Email notification sent to {recipient_type}: {recipient_display}", created_by=request.user, metadata={ 'recipient_type': recipient_type, 'recipient_id': str(recipient.id) if recipient else None, 'notification_log_id': str(notification_log.id) if notification_log else None } ) # Log audit AuditService.log_from_request( event_type='notification_sent', description=f"Email notification sent to {recipient_type}: {recipient_display}", request=request, content_object=complaint, metadata={ 'recipient_type': recipient_type, 'recipient_id': str(recipient.id) if recipient else None, 'recipient_email': recipient_email } ) return Response({ 'success': True, 'message': 'Email notification sent successfully', 'recipient': recipient_display, 'recipient_type': recipient_type, 'recipient_email': recipient_email }) @action(detail=True, methods=['post']) def request_explanation(self, request, pk=None): """ Request explanation from staff/recipient. Sends explanation link to staff member, and informational notification to manager. Manager only gets a link if/when the request escalates due to SLA breach. """ complaint = self.get_object() # Check if complaint is in active status if not complaint.is_active_status: return Response( {'error': f"Cannot request 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 to request explanation from if not complaint.staff: return Response( {'error': 'Complaint has no staff assigned to request explanation from'}, status=status.HTTP_400_BAD_REQUEST ) # Check if explanation already exists for this staff from .models import ComplaintExplanation existing_explanation = ComplaintExplanation.objects.filter( complaint=complaint, staff=complaint.staff ).first() if existing_explanation and existing_explanation.is_used: return Response( {'error': 'This staff member has already submitted an explanation'}, status=status.HTTP_400_BAD_REQUEST ) # Get optional message request_message = request.data.get('request_message', '').strip() # Get manager (report_to) if exists manager = complaint.staff.report_to if complaint.staff.report_to else None from django.contrib.sites.shortcuts import get_current_site from apps.notifications.services import NotificationService site = get_current_site(request) results = [] # === SEND TO STAFF MEMBER (with link) === # Generate unique token for staff import secrets staff_token = secrets.token_urlsafe(32) # Create or update explanation record for staff if existing_explanation: staff_explanation = existing_explanation staff_explanation.token = staff_token staff_explanation.is_used = False staff_explanation.requested_by = request.user staff_explanation.request_message = request_message staff_explanation.email_sent_at = timezone.now() staff_explanation.save() else: staff_explanation = ComplaintExplanation.objects.create( complaint=complaint, staff=complaint.staff, token=staff_token, is_used=False, submitted_via='email_link', requested_by=request.user, request_message=request_message, email_sent_at=timezone.now() ) # Determine staff email if complaint.staff.user and complaint.staff.user.email: staff_email = complaint.staff.user.email staff_display = str(complaint.staff) elif complaint.staff.email: staff_email = complaint.staff.email staff_display = str(complaint.staff) else: return Response( {'error': 'Staff member has no email address'}, status=status.HTTP_400_BAD_REQUEST ) # Build staff email staff_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{staff_token}/" staff_subject = f"Explanation Request - Complaint #{complaint.id}" staff_email_body = f""" Dear {staff_display}, We have received a complaint that requires your explanation. COMPLAINT DETAILS: ---------------- Reference: #{complaint.id} Title: {complaint.title} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} {complaint.description} """ # Add patient info if available if complaint.patient: staff_email_body += f""" PATIENT INFORMATION: ------------------ Name: {complaint.patient.get_full_name()} MRN: {complaint.patient.mrn} """ # Add request message if provided if request_message: staff_email_body += f""" ADDITIONAL MESSAGE: ------------------ {request_message} """ staff_email_body += f""" SUBMIT YOUR EXPLANATION: ------------------------ Your perspective is important. Please submit your explanation about this complaint: {staff_link} Note: This link can only be used once. After submission, it will expire. If you have any questions, please contact the PX team. --- This is an automated message from PX360 Complaint Management System. """ # Send email to staff try: staff_notification = NotificationService.send_email( email=staff_email, subject=staff_subject, message=staff_email_body, related_object=complaint, metadata={ 'notification_type': 'explanation_request', 'recipient_type': 'staff', 'staff_id': str(complaint.staff.id), 'explanation_id': str(staff_explanation.id), 'requested_by_id': str(request.user.id), 'has_request_message': bool(request_message) } ) results.append({ 'recipient_type': 'staff', 'recipient': staff_display, 'email': staff_email, 'explanation_id': str(staff_explanation.id), 'sent': True }) except Exception as e: results.append({ 'recipient_type': 'staff', 'recipient': staff_display, 'email': staff_email, 'sent': False, 'error': str(e) }) # === SEND NOTIFICATION TO MANAGER (informational only - no link) === manager_notified = False if manager: # Determine manager email if manager.user and manager.user.email: manager_email = manager.user.email elif manager.email: manager_email = manager.email else: manager_email = None if manager_email: manager_display = str(manager) manager_subject = f"Staff Explanation Request Notification - Complaint #{complaint.id}" manager_email_body = f""" Dear {manager_display}, This is an informational notification that an explanation has been requested from your team member. STAFF MEMBER: {staff_display} COMPLAINT DETAILS: ---------------- Reference: #{complaint.id} Title: {complaint.title} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} {complaint.description} """ # Add patient info if available if complaint.patient: manager_email_body += f""" PATIENT INFORMATION: ------------------ Name: {complaint.patient.get_full_name()} MRN: {complaint.patient.mrn} """ # Add request message if provided if request_message: manager_email_body += f""" ADDITIONAL MESSAGE: ------------------ {request_message} """ manager_email_body += f""" ACTION REQUIRED: ---------------- An explanation link has been sent directly to {staff_display}. If no response is received within the SLA deadline, you will receive a follow-up request with a link to provide your perspective as the manager. If you have any questions, please contact the PX team. --- This is an automated message from PX360 Complaint Management System. """ # Send informational email to manager try: manager_notification = NotificationService.send_email( email=manager_email, subject=manager_subject, message=manager_email_body, related_object=complaint, metadata={ 'notification_type': 'explanation_request_notification', 'recipient_type': 'manager', 'staff_id': str(manager.id), 'related_staff_id': str(complaint.staff.id), 'requested_by_id': str(request.user.id), 'has_request_message': bool(request_message), 'informational_only': True } ) results.append({ 'recipient_type': 'manager', 'recipient': manager_display, 'email': manager_email, 'sent': True, 'informational_only': True, 'note': 'Manager will receive link if staff does not respond within SLA' }) manager_notified = True except Exception as e: results.append({ 'recipient_type': 'manager', 'recipient': manager_display, 'email': manager_email, 'sent': False, 'informational_only': True, 'error': str(e) }) else: results.append({ 'recipient_type': 'manager', 'recipient': str(manager), 'sent': False, 'informational_only': True, 'error': 'Manager has no email address' }) # Create ComplaintUpdate entry recipients_str = ", ".join([r['recipient'] for r in results if r['sent']]) ComplaintUpdate.objects.create( complaint=complaint, update_type='communication', message=f"Explanation request sent to: {recipients_str}", created_by=request.user, metadata={ 'explanation_id': str(staff_explanation.id), 'staff_id': str(complaint.staff.id), 'manager_id': str(manager.id) if manager else None, 'manager_notified': manager_notified, 'results': results } ) # Log audit AuditService.log_from_request( event_type='explanation_requested', description=f"Explanation request sent to: {recipients_str}", request=request, content_object=complaint, metadata={ 'explanation_id': str(staff_explanation.id), 'staff_id': str(complaint.staff.id), 'manager_id': str(manager.id) if manager else None, 'manager_notified': manager_notified, 'request_message': request_message, 'results': results } ) # Check if at least staff email was sent staff_sent = any(r['recipient_type'] == 'staff' and r['sent'] for r in results) if not staff_sent: return Response({ 'success': False, 'message': 'Failed to send explanation request to staff member', 'results': results }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({ 'success': True, 'message': 'Explanation request sent successfully', 'results': results, 'staff_explanation_id': str(staff_explanation.id), 'manager_notified': manager_notified }) @action(detail=True, methods=['post']) def resend_explanation(self, request, pk=None): """ Resend explanation request email to staff member only. Regenerates the token with a new value and resends the email to the staff member. Manager is not resent the informational email - they already received it initially. Only allows resending if explanation has not been submitted yet. """ complaint = self.get_object() # Check if complaint is in active status if not complaint.is_active_status: return Response( {'error': f"Cannot resend explanation for complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved."}, status=status.HTTP_400_BAD_REQUEST ) # Check if complaint has staff assigned if not complaint.staff: return Response( {'error': 'No staff assigned to this complaint'}, status=status.HTTP_400_BAD_REQUEST ) # Check if explanation exists for this staff from .models import ComplaintExplanation try: explanation = ComplaintExplanation.objects.filter( complaint=complaint, staff=complaint.staff ).latest('created_at') except ComplaintExplanation.DoesNotExist: return Response( {'error': 'No explanation found for this complaint and staff'}, status=status.HTTP_404_NOT_FOUND ) # Check if already submitted (can only resend if not submitted) if explanation.is_used: return Response( {'error': 'Explanation already submitted, cannot resend. Create a new explanation request.'}, status=status.HTTP_400_BAD_REQUEST ) # Generate new token import secrets new_token = secrets.token_urlsafe(32) explanation.token = new_token explanation.email_sent_at = timezone.now() explanation.save() # Determine recipient email if complaint.staff.user and complaint.staff.user.email: recipient_email = complaint.staff.user.email recipient_display = str(complaint.staff) elif complaint.staff.email: recipient_email = complaint.staff.email recipient_display = str(complaint.staff) else: return Response( {'error': 'Staff member has no email address'}, status=status.HTTP_400_BAD_REQUEST ) # Send email with new link from django.contrib.sites.shortcuts import get_current_site from apps.notifications.services import NotificationService site = get_current_site(request) explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{new_token}/" # Build email subject subject = f"Explanation Request (Resent) - Complaint #{complaint.id}" # Build email body email_body = f""" Dear {recipient_display}, We have resent the explanation request for the following complaint: COMPLAINT DETAILS: ---------------- Reference: #{complaint.id} Title: {complaint.title} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} Status: {complaint.get_status_display()} {complaint.description} """ # Add patient info if available if complaint.patient: email_body += f""" PATIENT INFORMATION: ------------------ Name: {complaint.patient.get_full_name()} MRN: {complaint.patient.mrn} """ email_body += f""" SUBMIT YOUR EXPLANATION: ------------------------ Your perspective is important. Please submit your explanation about this complaint: {explanation_link} Note: This link can only be used once. After submission, it will expire. If you have any questions, please contact PX team. --- This is an automated message from PX360 Complaint Management System. """ # Send email try: notification_log = NotificationService.send_email( email=recipient_email, subject=subject, message=email_body, related_object=complaint, metadata={ 'notification_type': 'explanation_request_resent', 'recipient_type': 'staff', 'staff_id': str(complaint.staff.id), 'explanation_id': str(explanation.id), 'requested_by_id': str(request.user.id), 'resent': True } ) except Exception as e: return Response( {'error': f'Failed to send email: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # Create ComplaintUpdate entry ComplaintUpdate.objects.create( complaint=complaint, update_type='communication', message=f"Explanation request resent to {recipient_display}", created_by=request.user, metadata={ 'explanation_id': str(explanation.id), 'staff_id': str(complaint.staff.id), 'notification_log_id': str(notification_log.id) if notification_log else None, 'resent': True } ) # Log audit AuditService.log_from_request( event_type='explanation_resent', description=f"Explanation request resent to {recipient_display}", request=request, content_object=complaint, metadata={ 'explanation_id': str(explanation.id), 'staff_id': str(complaint.staff.id) } ) return Response({ 'success': True, 'message': 'Explanation request resent successfully to staff member', 'explanation_id': str(explanation.id), 'recipient': recipient_display, 'new_token': new_token, 'explanation_link': explanation_link }, status=status.HTTP_200_OK) @action(detail=True, methods=['post']) def review_explanation(self, request, pk=None): """ Review and mark an explanation as acceptable or not acceptable. Allows PX Admins to review submitted explanations and mark them. """ complaint = self.get_object() # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( {'error': 'Only PX Admins or Hospital Admins can review explanations'}, status=status.HTTP_403_FORBIDDEN ) explanation_id = request.data.get('explanation_id') acceptance_status = request.data.get('acceptance_status') acceptance_notes = request.data.get('acceptance_notes', '') if not explanation_id: return Response( {'error': 'explanation_id is required'}, status=status.HTTP_400_BAD_REQUEST ) if not acceptance_status: return Response( {'error': 'acceptance_status is required (acceptable or not_acceptable)'}, status=status.HTTP_400_BAD_REQUEST ) # Validate acceptance status from .models import ComplaintExplanation valid_statuses = [ComplaintExplanation.AcceptanceStatus.ACCEPTABLE, ComplaintExplanation.AcceptanceStatus.NOT_ACCEPTABLE] if acceptance_status not in valid_statuses: return Response( {'error': f'Invalid acceptance_status. Must be one of: {valid_statuses}'}, status=status.HTTP_400_BAD_REQUEST ) # Get the explanation try: explanation = ComplaintExplanation.objects.get( id=explanation_id, complaint=complaint ) except ComplaintExplanation.DoesNotExist: return Response( {'error': 'Explanation not found'}, status=status.HTTP_404_NOT_FOUND ) # Check if explanation has been submitted if not explanation.is_used: return Response( {'error': 'Cannot review explanation that has not been submitted yet'}, status=status.HTTP_400_BAD_REQUEST ) # Update explanation explanation.acceptance_status = acceptance_status explanation.accepted_by = request.user explanation.accepted_at = timezone.now() explanation.acceptance_notes = acceptance_notes explanation.save() # Create complaint update status_display = "Acceptable" if acceptance_status == ComplaintExplanation.AcceptanceStatus.ACCEPTABLE else "Not Acceptable" ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=f"Explanation from {explanation.staff} marked as {status_display}", created_by=request.user, metadata={ 'explanation_id': str(explanation.id), 'staff_id': str(explanation.staff.id) if explanation.staff else None, 'acceptance_status': acceptance_status, 'acceptance_notes': acceptance_notes } ) # Log audit AuditService.log_from_request( event_type='explanation_reviewed', description=f"Explanation marked as {status_display}", request=request, content_object=explanation, metadata={ 'explanation_id': str(explanation.id), 'acceptance_status': acceptance_status, 'acceptance_notes': acceptance_notes } ) return Response({ 'success': True, 'message': f'Explanation marked as {status_display}', 'explanation_id': str(explanation.id), 'acceptance_status': acceptance_status, 'accepted_at': explanation.accepted_at, 'accepted_by': request.user.get_full_name() }) @action(detail=True, methods=['post']) def escalate_explanation(self, request, pk=None): """ Escalate an explanation to the staff's manager. Marks the explanation as not acceptable and sends an explanation request to the staff's manager (report_to). """ complaint = self.get_object() # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( {'error': 'Only PX Admins or Hospital Admins can escalate explanations'}, status=status.HTTP_403_FORBIDDEN ) explanation_id = request.data.get('explanation_id') acceptance_notes = request.data.get('acceptance_notes', '') if not explanation_id: return Response( {'error': 'explanation_id is required'}, status=status.HTTP_400_BAD_REQUEST ) # Get the explanation try: explanation = ComplaintExplanation.objects.select_related( 'staff', 'staff__report_to' ).get( id=explanation_id, complaint=complaint ) except ComplaintExplanation.DoesNotExist: return Response( {'error': 'Explanation not found'}, status=status.HTTP_404_NOT_FOUND ) # Check if explanation has been submitted if not explanation.is_used: return Response( {'error': 'Cannot escalate explanation that has not been submitted yet'}, status=status.HTTP_400_BAD_REQUEST ) # Check if already escalated if explanation.escalated_to_manager: return Response( {'error': 'Explanation has already been escalated'}, status=status.HTTP_400_BAD_REQUEST ) # Check if staff has a manager if not explanation.staff or not explanation.staff.report_to: return Response( {'error': 'Staff member does not have a manager (report_to) assigned'}, status=status.HTTP_400_BAD_REQUEST ) manager = explanation.staff.report_to # Check if manager already has an explanation request for this complaint existing_manager_explanation = ComplaintExplanation.objects.filter( complaint=complaint, staff=manager ).first() if existing_manager_explanation: return Response( {'error': f'Manager {manager.get_full_name()} already has an explanation request for this complaint'}, status=status.HTTP_400_BAD_REQUEST ) # Generate token for manager explanation import secrets manager_token = secrets.token_urlsafe(32) # Create manager explanation record manager_explanation = ComplaintExplanation.objects.create( complaint=complaint, staff=manager, token=manager_token, is_used=False, requested_by=request.user, request_message=f"Escalated from staff explanation. Staff: {explanation.staff.get_full_name() if explanation.staff else 'Unknown'}. Notes: {acceptance_notes}", submitted_via='email_link', email_sent_at=timezone.now() ) # Update original explanation explanation.acceptance_status = ComplaintExplanation.AcceptanceStatus.NOT_ACCEPTABLE explanation.accepted_by = request.user explanation.accepted_at = timezone.now() explanation.acceptance_notes = acceptance_notes explanation.escalated_to_manager = manager_explanation explanation.escalated_at = timezone.now() explanation.save() # Send email to manager from django.contrib.sites.shortcuts import get_current_site from apps.notifications.services import NotificationService site = get_current_site(request) explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{manager_token}/" manager_email = manager.email or (manager.user.email if manager.user else None) if manager_email: subject = f"Escalated Explanation Request - Complaint #{complaint.reference_number}" email_body = f"""Dear {manager.get_full_name()}, An explanation submitted by a staff member who reports to you has been marked as not acceptable and escalated to you for further review. STAFF MEMBER: ------------ Name: {explanation.staff.get_full_name() if explanation.staff else 'Unknown'} Employee ID: {explanation.staff.employee_id if explanation.staff else 'N/A'} Department: {explanation.staff.department.name if explanation.staff and explanation.staff.department else 'N/A'} COMPLAINT DETAILS: ---------------- Reference: {complaint.reference_number} Title: {complaint.title} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} ORIGINAL EXPLANATION (Not Acceptable): -------------------------------------- {explanation.explanation} ESCALATION NOTES: ----------------- {acceptance_notes if acceptance_notes else 'No additional notes provided.'} PLEASE SUBMIT YOUR EXPLANATION: ------------------------------ As the manager, please submit your perspective on this matter: {explanation_link} Note: This link can only be used once. After submission, it will expire. --- This is an automated message from PX360 Complaint Management System. """ try: NotificationService.send_email( email=manager_email, subject=subject, message=email_body, related_object=complaint, metadata={ 'notification_type': 'escalated_explanation_request', 'manager_id': str(manager.id), 'staff_id': str(explanation.staff.id) if explanation.staff else None, 'complaint_id': str(complaint.id), 'original_explanation_id': str(explanation.id), } ) email_sent = True except Exception as e: logger.error(f"Failed to send escalation email to manager: {e}") email_sent = False else: email_sent = False # Create complaint update ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=f"Explanation from {explanation.staff} marked as Not Acceptable and escalated to manager {manager.get_full_name()}", created_by=request.user, metadata={ 'explanation_id': str(explanation.id), 'staff_id': str(explanation.staff.id) if explanation.staff else None, 'manager_id': str(manager.id), 'manager_explanation_id': str(manager_explanation.id), 'acceptance_status': 'not_acceptable', 'acceptance_notes': acceptance_notes, 'email_sent': email_sent } ) # Log audit AuditService.log_from_request( event_type='explanation_escalated', description=f"Explanation escalated to manager {manager.get_full_name()}", request=request, content_object=explanation, metadata={ 'explanation_id': str(explanation.id), 'manager_id': str(manager.id), 'manager_explanation_id': str(manager_explanation.id), 'email_sent': email_sent } ) return Response({ 'success': True, 'message': f'Explanation escalated to manager {manager.get_full_name()}', 'explanation_id': str(explanation.id), 'manager_explanation_id': str(manager_explanation.id), 'manager_name': manager.get_full_name(), 'manager_email': manager_email, 'email_sent': email_sent }) @action(detail=True, methods=['post']) def generate_ai_resolution(self, request, pk=None): """ Generate AI-powered resolution note based on complaint details and explanations. Analyzes the complaint description, staff explanations, and manager explanations to generate a comprehensive resolution note for admin review. """ complaint = self.get_object() # Check permission - same logic as can_manage_complaint user = request.user can_generate = ( user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital) or (user.is_department_manager() and user.department == complaint.department) or complaint.assigned_to == user ) if not can_generate: return Response( {'error': 'You do not have permission to generate AI resolution for this complaint'}, status=status.HTTP_403_FORBIDDEN ) # Get all used explanations explanations = complaint.explanations.filter(is_used=True).select_related('staff') if not explanations.exists(): return Response({ 'success': False, 'error': 'No explanations available to analyze. Please request explanations first.' }, status=status.HTTP_400_BAD_REQUEST) # Build context for AI context = { 'complaint': { 'title': complaint.title, 'description': complaint.description, 'severity': complaint.get_severity_display(), 'priority': complaint.get_priority_display(), 'patient_name': complaint.patient.get_full_name() if complaint.patient else 'Unknown', 'department': complaint.department.name if complaint.department else 'Unknown', }, 'explanations': [] } for exp in explanations: exp_data = { 'staff_name': exp.staff.get_full_name() if exp.staff else 'Unknown', 'employee_id': exp.staff.employee_id if exp.staff else 'N/A', 'department': exp.staff.department.name if exp.staff and exp.staff.department else 'N/A', 'explanation': exp.explanation, 'acceptance_status': exp.get_acceptance_status_display(), 'submitted_at': exp.responded_at.strftime('%Y-%m-%d %H:%M') if exp.responded_at else 'Unknown' } context['explanations'].append(exp_data) # Call AI service to generate resolution try: from apps.core.ai_service import AIService # Build prompt explanations_text = "" for i, exp in enumerate(context['explanations'], 1): explanations_text += f""" Explanation {i}: - Staff: {exp['staff_name']} (ID: {exp['employee_id']}, Dept: {exp['department']}) - Status: {exp['acceptance_status']} - Submitted: {exp['submitted_at']} - Content: {exp['explanation']} """ prompt = f"""As a healthcare complaint resolution expert, analyze the following complaint and staff explanations to generate a comprehensive resolution note in BOTH English and Arabic. COMPLAINT DETAILS: - Title: {context['complaint']['title']} - Description: {context['complaint']['description']} - Severity: {context['complaint']['severity']} - Priority: {context['complaint']['priority']} - Patient: {context['complaint']['patient_name']} - Department: {context['complaint']['department']} STAFF EXPLANATIONS: {explanations_text} Based on the above information, generate a professional resolution note that: 1. Summarizes the main issue and root cause 2. References the key points from staff explanations 3. States the outcome/decision 4. Includes any corrective actions taken or planned 5. Addresses patient concerns 6. Mentions any follow-up actions The resolution should be written in a professional, empathetic tone suitable for healthcare settings. IMPORTANT: Provide the resolution in BOTH languages as JSON: {{ "resolution_en": "The resolution text in English (3-5 paragraphs)", "resolution_ar": "نص القرار بالعربية (3-5 فقرات)" }} Ensure both versions convey the same meaning and are professionally written.""" system_prompt = """You are an expert healthcare complaint resolution specialist fluent in both English and Arabic. Your task is to analyze complaints and staff explanations to generate comprehensive, professional resolution notes in both languages. Be objective, empathetic, and thorough. Focus on facts while acknowledging the patient's concerns. Write in a professional tone appropriate for medical records in both languages. Always provide valid JSON output with both resolution_en and resolution_ar fields.""" ai_response = AIService.chat_completion( prompt=prompt, system_prompt=system_prompt, temperature=0.4, max_tokens=1500, response_format='json_object' ) # Parse the JSON response import json resolution_data = json.loads(ai_response) resolution_en = resolution_data.get('resolution_en', '').strip() resolution_ar = resolution_data.get('resolution_ar', '').strip() # Log the AI generation AuditService.log_from_request( event_type='ai_resolution_generated', description=f"AI resolution generated for complaint {complaint.reference_number}", request=request, content_object=complaint, metadata={ 'complaint_id': str(complaint.id), 'explanation_count': explanations.count(), 'generated_resolution_en_length': len(resolution_en), 'generated_resolution_ar_length': len(resolution_ar) } ) return Response({ 'success': True, 'resolution_en': resolution_en, 'resolution_ar': resolution_ar, 'explanation_count': explanations.count() }) except Exception as e: logger.error(f"AI resolution generation failed: {e}") return Response({ 'success': False, 'error': f'Failed to generate resolution: {str(e)}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @action(detail=True, methods=['get']) def generate_resolution_suggestion(self, request, pk=None): """ Generate AI resolution suggestion based on complaint and acceptable explanation. Uses the staff explanation if acceptable, otherwise uses manager explanation. Returns a suggested resolution text that can be edited or used directly. """ complaint = self.get_object() # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( {'error': 'Only PX Admins or Hospital Admins can generate resolution suggestions'}, status=status.HTTP_403_FORBIDDEN ) # Find acceptable explanation acceptable_explanation = None explanation_source = None # First, try to find an acceptable staff explanation staff_explanation = complaint.explanations.filter( staff=complaint.staff, is_used=True, acceptance_status=ComplaintExplanation.AcceptanceStatus.ACCEPTABLE ).first() if staff_explanation: acceptable_explanation = staff_explanation explanation_source = "staff" else: # Try to find an acceptable manager explanation (escalated) manager_explanation = complaint.explanations.filter( is_used=True, acceptance_status=ComplaintExplanation.AcceptanceStatus.ACCEPTABLE, metadata__is_escalation=True ).first() if manager_explanation: acceptable_explanation = manager_explanation explanation_source = "manager" if not acceptable_explanation: return Response({ 'error': 'No acceptable explanation found. Please review and mark an explanation as acceptable first.', 'suggestion': None }, status=status.HTTP_400_BAD_REQUEST) # Generate resolution using AI try: resolution_text = self._generate_ai_resolution( complaint=complaint, explanation=acceptable_explanation, source=explanation_source ) return Response({ 'success': True, 'suggestion': resolution_text, 'source': explanation_source, 'source_staff': acceptable_explanation.staff.get_full_name() if acceptable_explanation.staff else None, 'explanation_id': str(acceptable_explanation.id) }) except Exception as e: logger.error(f"Failed to generate resolution: {e}") return Response({ 'error': 'Failed to generate resolution suggestion', 'detail': str(e) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) def _generate_ai_resolution(self, complaint, explanation, source): """ Generate AI resolution text based on complaint and explanation. This is a stub implementation. Replace with actual AI service call. """ # Build context for AI complaint_details = f""" Complaint Title: {complaint.title} Complaint Description: {complaint.description} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} """ explanation_text = explanation.explanation explanation_by = explanation.staff.get_full_name() if explanation.staff else "Unknown" # For now, generate a template-based resolution # This should be replaced with actual AI service call resolution = f"""RESOLUTION SUMMARY Based on the complaint filed regarding: {complaint.title} INVESTIGATION FINDINGS: After reviewing the complaint and the explanation provided by {explanation_by} ({source}), the following has been determined: {explanation_text} RESOLUTION: The matter has been addressed through appropriate channels. ACTIONS TAKEN: - The issue has been reviewed and investigated thoroughly - Appropriate measures have been implemented to address the concern - Steps have been taken to prevent recurrence The complaint is considered resolved.""" return resolution @action(detail=True, methods=['post']) def save_resolution(self, request, pk=None): """ Save final resolution for the complaint. Allows user to save an edited or directly generated resolution. Optionally updates complaint status to RESOLVED. """ complaint = self.get_object() # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return Response( {'error': 'Only PX Admins or Hospital Admins can save resolutions'}, status=status.HTTP_403_FORBIDDEN ) resolution_text = request.data.get('resolution') mark_resolved = request.data.get('mark_resolved', False) if not resolution_text: return Response( {'error': 'Resolution text is required'}, status=status.HTTP_400_BAD_REQUEST ) # Save resolution complaint.resolution = resolution_text complaint.resolution_category = ComplaintResolutionCategory.FULL_ACTION_TAKEN if mark_resolved: complaint.status = ComplaintStatus.RESOLVED complaint.resolved_at = timezone.now() complaint.resolved_by = request.user complaint.save() # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type='resolution', message=f"Resolution added{' and complaint marked as resolved' if mark_resolved else ''}", created_by=request.user, metadata={ 'resolution_category': ComplaintResolutionCategory.FULL_ACTION_TAKEN, 'mark_resolved': mark_resolved } ) # Log audit AuditService.log_from_request( event_type='resolution_saved', description=f"Resolution saved{' and complaint resolved' if mark_resolved else ''}", request=request, content_object=complaint, metadata={'mark_resolved': mark_resolved} ) return Response({ 'success': True, 'message': f"Resolution saved successfully{' and complaint marked as resolved' if mark_resolved else ''}", 'complaint_id': str(complaint.id), 'status': complaint.status }) @action(detail=True, methods=['post']) def convert_to_appreciation(self, request, pk=None): """ Convert complaint to appreciation. Creates an Appreciation record from a complaint marked as 'appreciation' type. Maps complaint data to appreciation fields and links both records. Optionally closes the complaint after conversion. """ complaint = self.get_object() # Check if complaint is in active status if not complaint.is_active_status: return Response( {'error': f"Cannot convert complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved."}, status=status.HTTP_400_BAD_REQUEST ) # Check if complaint is appreciation type if complaint.complaint_type != 'appreciation': return Response( {'error': 'Only appreciation-type complaints can be converted to appreciations'}, status=status.HTTP_400_BAD_REQUEST ) # Check if already converted if complaint.metadata.get('appreciation_id'): return Response( {'error': 'This complaint has already been converted to an appreciation'}, status=status.HTTP_400_BAD_REQUEST ) # Get form data recipient_type = request.data.get('recipient_type', 'user') # 'user' or 'physician' recipient_id = request.data.get('recipient_id') category_id = request.data.get('category_id') message_en = request.data.get('message_en', complaint.description) message_ar = request.data.get('message_ar', complaint.short_description_ar or '') visibility = request.data.get('visibility', 'private') is_anonymous = request.data.get('is_anonymous', True) close_complaint = request.data.get('close_complaint', False) # Validate recipient from django.contrib.contenttypes.models import ContentType if recipient_type == 'user': from apps.accounts.models import User try: recipient_user = User.objects.get(id=recipient_id) recipient_content_type = ContentType.objects.get_for_model(User) recipient_object_id = recipient_user.id except User.DoesNotExist: return Response( {'error': 'Recipient user not found'}, status=status.HTTP_404_NOT_FOUND ) elif recipient_type == 'physician': from apps.physicians.models import Physician try: recipient_physician = Physician.objects.get(id=recipient_id) recipient_content_type = ContentType.objects.get_for_model(Physician) recipient_object_id = recipient_physician.id except Physician.DoesNotExist: return Response( {'error': 'Recipient physician not found'}, status=status.HTTP_404_NOT_FOUND ) else: return Response( {'error': 'Invalid recipient_type. Must be "user" or "physician"'}, status=status.HTTP_400_BAD_REQUEST ) # Validate category from apps.appreciation.models import AppreciationCategory try: category = AppreciationCategory.objects.get(id=category_id) except AppreciationCategory.DoesNotExist: return Response( {'error': 'Appreciation category not found'}, status=status.HTTP_404_NOT_FOUND ) # Determine sender (patient or anonymous) sender = None if not is_anonymous and complaint.patient and complaint.patient.user: sender = complaint.patient.user # Create Appreciation from apps.appreciation.models import Appreciation appreciation = Appreciation.objects.create( sender=sender, recipient_content_type=recipient_content_type, recipient_object_id=recipient_object_id, hospital=complaint.hospital, department=complaint.department, category=category, message_en=message_en, message_ar=message_ar, visibility=visibility, status=Appreciation.AppreciationStatus.DRAFT, is_anonymous=is_anonymous, metadata={ 'source_complaint_id': str(complaint.id), 'source_complaint_title': complaint.title, 'converted_from_complaint': True, 'converted_by': str(request.user.id), 'converted_at': timezone.now().isoformat() } ) # Send appreciation (triggers notification) appreciation.send() # Link appreciation to complaint if not complaint.metadata: complaint.metadata = {} complaint.metadata['appreciation_id'] = str(appreciation.id) complaint.metadata['converted_to_appreciation'] = True complaint.metadata['converted_to_appreciation_at'] = timezone.now().isoformat() complaint.metadata['converted_by'] = str(request.user.id) complaint.save(update_fields=['metadata']) # Close complaint if requested complaint_closed = False if close_complaint: complaint.status = 'closed' complaint.closed_at = timezone.now() complaint.closed_by = request.user complaint.save(update_fields=['status', 'closed_at', 'closed_by']) complaint_closed = True # Create status update ComplaintUpdate.objects.create( complaint=complaint, update_type='status_change', message="Complaint closed after converting to appreciation", created_by=request.user, old_status='open', new_status='closed' ) # Create conversion update ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=f"Converted to appreciation (Appreciation #{appreciation.id})", created_by=request.user, metadata={ 'appreciation_id': str(appreciation.id), 'converted_from_complaint': True, 'close_complaint': close_complaint } ) # Log audit AuditService.log_from_request( event_type='complaint_converted_to_appreciation', description=f"Complaint converted to appreciation: {appreciation.message_en[:100]}", request=request, content_object=complaint, metadata={ 'appreciation_id': str(appreciation.id), 'close_complaint': close_complaint, 'is_anonymous': is_anonymous } ) # Build appreciation URL from django.contrib.sites.shortcuts import get_current_site site = get_current_site(request) appreciation_url = f"https://{site.domain}/appreciations/{appreciation.id}/" return Response({ 'success': True, 'message': 'Complaint successfully converted to appreciation', 'appreciation_id': str(appreciation.id), 'appreciation_url': appreciation_url, 'complaint_closed': complaint_closed }, status=status.HTTP_201_CREATED) @action(detail=True, methods=['post']) def send_resolution_notification(self, request, pk=None): """ Send resolution notification to patient. Sends email notification to patient with resolution details. Optionally sends SMS if phone number is available. Creates ComplaintUpdate entry and logs audit trail. """ complaint = self.get_object() # Check if complaint is resolved if complaint.status != 'resolved': return Response( {'error': 'Can only send resolution notification for resolved complaints'}, status=status.HTTP_400_BAD_REQUEST ) # Check if resolution exists if not complaint.resolution: return Response( {'error': 'Complaint must have resolution details before sending notification'}, status=status.HTTP_400_BAD_REQUEST ) # Determine recipient (patient or contact) recipient_email = None recipient_phone = None recipient_name = None # Try patient first if complaint.patient: if complaint.patient.email: recipient_email = complaint.patient.email if complaint.patient.phone: recipient_phone = complaint.patient.phone recipient_name = complaint.patient.get_full_name() # Fall back to contact info if not recipient_email: recipient_email = complaint.contact_email if not recipient_name: recipient_name = complaint.contact_name if not recipient_phone: recipient_phone = complaint.contact_phone # Validate at least email is available if not recipient_email: return Response( {'error': 'No email address found for patient or contact'}, status=status.HTTP_400_BAD_REQUEST ) # Build email subject and body subject = f"Complaint Resolution - #{complaint.id}" # Build email body email_body = f""" Dear {recipient_name}, We are pleased to inform you that your complaint has been resolved. COMPLAINT DETAILS: ---------------- Reference: #{complaint.id} Title: {complaint.title} Status: {complaint.get_status_display()} RESOLUTION: ----------- Category: {complaint.get_resolution_category_display()} {complaint.resolution} """ # Add additional context if available if complaint.resolved_by: email_body += f""" Resolved by: {complaint.resolved_by.get_full_name()} Resolved at: {complaint.resolved_at.strftime('%Y-%m-%d %H:%M')} """ email_body += f""" If you have any further questions or concerns about this resolution, please don't hesitate to contact us. Thank you for your patience and for giving us the opportunity to address your concerns. --- This is an automated message from PX360 Complaint Management System. """ # Send email using NotificationService from apps.notifications.services import NotificationService try: notification_log = NotificationService.send_email( email=recipient_email, subject=subject, message=email_body, related_object=complaint, metadata={ 'notification_type': 'resolution_notification', 'recipient_name': recipient_name, 'recipient_phone': recipient_phone, 'sender_id': str(request.user.id), 'resolution_category': complaint.resolution_category } ) except Exception as e: return Response( {'error': f'Failed to send email: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # Optionally send SMS if phone is available sms_sent = False if recipient_phone: try: # Build SMS message (shorter) sms_message = f"PX360: Your complaint #{complaint.id} has been resolved. Resolution Category: {complaint.get_resolution_category_display()}. Check your email for details." # Send SMS (if SMS service is configured) # This is a placeholder - actual SMS sending depends on your SMS provider sms_sent = True # Set to True if SMS is actually sent if sms_sent: # Log SMS in metadata complaint.metadata['resolution_sms_sent_at'] = timezone.now().isoformat() complaint.metadata['resolution_sms_sent_to'] = recipient_phone complaint.save(update_fields=['metadata']) except Exception as e: # Log error but don't fail the operation import logging logger = logging.getLogger(__name__) logger.error(f"Failed to send SMS: {e}") # Create ComplaintUpdate entry ComplaintUpdate.objects.create( complaint=complaint, update_type='communication', message=f"Resolution notification sent to {recipient_name}", created_by=request.user, metadata={ 'notification_type': 'resolution_notification', 'recipient_name': recipient_name, 'recipient_email': recipient_email, 'notification_log_id': str(notification_log.id) if notification_log else None, 'sms_sent': sms_sent } ) # Log audit AuditService.log_from_request( event_type='resolution_notification_sent', description=f"Resolution notification sent to {recipient_name}", request=request, content_object=complaint, metadata={ 'recipient_name': recipient_name, 'recipient_email': recipient_email, 'recipient_phone': recipient_phone, 'sms_sent': sms_sent, 'resolution_category': complaint.resolution_category } ) return Response({ 'success': True, 'message': 'Resolution notification sent successfully', 'recipient': recipient_name, 'recipient_email': recipient_email, 'sms_sent': sms_sent }) @action(detail=True, methods=['post']) def update_taxonomy(self, request, pk=None): """ Update the 4-level SHCT taxonomy classification for a complaint. Allows PX Admins and Hospital Admins to manually correct or update the AI-generated taxonomy classification. Required fields: - domain_id: UUID of the Level 1 Domain (ComplaintCategory) - category_id: UUID of the Level 2 Category (ComplaintCategory) - subcategory_id: UUID of the Level 3 Subcategory (ComplaintCategory) - classification_id: UUID of the Level 4 Classification (ComplaintCategory) Optional fields: - note: Optional note explaining the change """ complaint = self.get_object() user = request.user # Check permissions if not (user.is_px_admin() or user.is_hospital_admin()): return Response( {'error': 'Only PX Admins and Hospital Admins can update taxonomy'}, status=status.HTTP_403_FORBIDDEN ) # Get taxonomy IDs from request domain_id = request.data.get('domain_id') category_id = request.data.get('category_id') subcategory_id = request.data.get('subcategory_id') classification_id = request.data.get('classification_id') note = request.data.get('note', '') # Validate that at least one field is provided if not any([domain_id, category_id, subcategory_id, classification_id]): return Response( {'error': 'At least one taxonomy level (domain_id, category_id, subcategory_id, or classification_id) must be provided'}, status=status.HTTP_400_BAD_REQUEST ) from apps.complaints.models import ComplaintCategory changes = [] errors = [] # Store old values for logging old_domain = complaint.domain old_category = complaint.category old_subcategory_obj = complaint.subcategory_obj old_classification_obj = complaint.classification_obj try: # Level 1: Domain if domain_id: try: domain = ComplaintCategory.objects.get( id=domain_id, level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True ) complaint.domain = domain changes.append(f"Domain: {old_domain.name_en if old_domain else 'None'} -> {domain.name_en}") except ComplaintCategory.DoesNotExist: errors.append(f"Domain with ID {domain_id} not found or not active") # Level 2: Category (must be child of domain if domain is set) if category_id: try: category_query = ComplaintCategory.objects.filter( id=category_id, level=ComplaintCategory.LevelChoices.CATEGORY, is_active=True ) # If domain is set, ensure category is child of domain if complaint.domain: category_query = category_query.filter(parent=complaint.domain) category = category_query.first() if category: complaint.category = category changes.append(f"Category: {old_category.name_en if old_category else 'None'} -> {category.name_en}") else: errors.append(f"Category with ID {category_id} not found, not active, or not under the selected domain") except Exception as e: errors.append(f"Error setting category: {str(e)}") # Level 3: Subcategory (must be child of category if category is set) if subcategory_id: try: subcategory_query = ComplaintCategory.objects.filter( id=subcategory_id, level=ComplaintCategory.LevelChoices.SUBCATEGORY, is_active=True ) # If category is set, ensure subcategory is child of category if complaint.category: subcategory_query = subcategory_query.filter(parent=complaint.category) subcategory = subcategory_query.first() if subcategory: complaint.subcategory_obj = subcategory complaint.subcategory = subcategory.code or subcategory.name_en changes.append(f"Subcategory: {old_subcategory_obj.name_en if old_subcategory_obj else 'None'} -> {subcategory.name_en}") else: errors.append(f"Subcategory with ID {subcategory_id} not found, not active, or not under the selected category") except Exception as e: errors.append(f"Error setting subcategory: {str(e)}") # Level 4: Classification (must be child of subcategory if subcategory is set) if classification_id: try: classification_query = ComplaintCategory.objects.filter( id=classification_id, level=ComplaintCategory.LevelChoices.CLASSIFICATION, is_active=True ) # If subcategory_obj is set, ensure classification is child of subcategory if complaint.subcategory_obj: classification_query = classification_query.filter(parent=complaint.subcategory_obj) classification = classification_query.first() if classification: complaint.classification_obj = classification complaint.classification = classification.code or classification.name_en changes.append(f"Classification: {old_classification_obj.name_en if old_classification_obj else 'None'} -> {classification.name_en}") else: errors.append(f"Classification with ID {classification_id} not found, not active, or not under the selected subcategory") except Exception as e: errors.append(f"Error setting classification: {str(e)}") # If there were errors, return them without saving if errors: return Response( { 'error': 'Some taxonomy levels could not be updated', 'errors': errors, 'changes_made': changes }, status=status.HTTP_400_BAD_REQUEST ) # Save the complaint complaint.save(update_fields=['domain', 'category', 'subcategory', 'subcategory_obj', 'classification', 'classification_obj']) # Create timeline entry change_message = "Taxonomy updated:\n" + "\n".join(changes) if note: change_message += f"\n\nNote: {note}" ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=change_message, created_by=user, metadata={ 'taxonomy_update': True, 'changes': changes, 'note': note, 'updated_by': str(user.id) } ) # Log audit AuditService.log_from_request( event_type='taxonomy_updated', description=f"Taxonomy updated for complaint: {complaint.title}", request=request, content_object=complaint, metadata={ 'changes': changes, 'note': note, 'updated_by': str(user.id) } ) # Update metadata to reflect manual update if not complaint.metadata: complaint.metadata = {} if 'ai_analysis' not in complaint.metadata: complaint.metadata['ai_analysis'] = {} complaint.metadata['ai_analysis']['taxonomy_manually_updated'] = True complaint.metadata['ai_analysis']['taxonomy_updated_by'] = str(user.id) complaint.metadata['ai_analysis']['taxonomy_updated_at'] = timezone.now().isoformat() complaint.save(update_fields=['metadata']) return Response({ 'success': True, 'message': 'Taxonomy updated successfully', 'changes': changes, 'taxonomy': { 'domain': { 'id': str(complaint.domain.id) if complaint.domain else None, 'name_en': complaint.domain.name_en if complaint.domain else None, 'name_ar': complaint.domain.name_ar if complaint.domain else None }, 'category': { 'id': str(complaint.category.id) if complaint.category else None, 'name_en': complaint.category.name_en if complaint.category else None, 'name_ar': complaint.category.name_ar if complaint.category else None }, 'subcategory': { 'id': str(complaint.subcategory_obj.id) if complaint.subcategory_obj else None, 'name_en': complaint.subcategory_obj.name_en if complaint.subcategory_obj else None, 'name_ar': complaint.subcategory_obj.name_ar if complaint.subcategory_obj else None, 'code': complaint.subcategory }, 'classification': { 'id': str(complaint.classification_obj.id) if complaint.classification_obj else None, 'name_en': complaint.classification_obj.name_en if complaint.classification_obj else None, 'name_ar': complaint.classification_obj.name_ar if complaint.classification_obj else None, 'code': complaint.classification } } }) except Exception as e: logger.error(f"Error updating taxonomy: {str(e)}") return Response( {'error': f'Failed to update taxonomy: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @action(detail=True, methods=['get']) def taxonomy_options(self, request, pk=None): """ Get available taxonomy options for the complaint's hierarchy. Returns the full SHCT taxonomy hierarchy for building cascading dropdowns. Includes only active categories. """ complaint = self.get_object() from apps.complaints.models import ComplaintCategory from django.db.models import Prefetch # Build the hierarchy domains = ComplaintCategory.objects.filter( level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True ).order_by('order', 'name_en') result = [] for domain in domains: domain_data = { 'id': str(domain.id), 'code': domain.code or domain.name_en.upper(), 'name_en': domain.name_en, 'name_ar': domain.name_ar, 'is_selected': complaint.domain and complaint.domain.id == domain.id, 'categories': [] } # Get categories for this domain categories = ComplaintCategory.objects.filter( parent=domain, level=ComplaintCategory.LevelChoices.CATEGORY, is_active=True ).order_by('order', 'name_en') for category in categories: category_data = { 'id': str(category.id), 'code': category.code or category.name_en.upper(), 'name_en': category.name_en, 'name_ar': category.name_ar, 'is_selected': complaint.category and complaint.category.id == category.id, 'subcategories': [] } # Get subcategories for this category subcategories = ComplaintCategory.objects.filter( parent=category, level=ComplaintCategory.LevelChoices.SUBCATEGORY, is_active=True ).order_by('order', 'name_en') for subcategory in subcategories: subcategory_data = { 'id': str(subcategory.id), 'code': subcategory.code or subcategory.name_en.upper(), 'name_en': subcategory.name_en, 'name_ar': subcategory.name_ar, 'is_selected': complaint.subcategory_obj and complaint.subcategory_obj.id == subcategory.id, 'classifications': [] } # Get classifications for this subcategory classifications = ComplaintCategory.objects.filter( parent=subcategory, level=ComplaintCategory.LevelChoices.CLASSIFICATION, is_active=True ).order_by('order', 'name_en') for classification in classifications: classification_data = { 'id': str(classification.id), 'code': classification.code, 'name_en': classification.name_en, 'name_ar': classification.name_ar, 'is_selected': complaint.classification_obj and complaint.classification_obj.id == classification.id } subcategory_data['classifications'].append(classification_data) category_data['subcategories'].append(subcategory_data) domain_data['categories'].append(category_data) result.append(domain_data) return Response({ 'success': True, 'hierarchy': result, 'current': { 'domain_id': str(complaint.domain.id) if complaint.domain else None, 'category_id': str(complaint.category.id) if complaint.category else None, 'subcategory_id': str(complaint.subcategory_obj.id) if complaint.subcategory_obj else None, 'classification_id': str(complaint.classification_obj.id) if complaint.classification_obj else None } }) class ComplaintAttachmentViewSet(viewsets.ModelViewSet): """ViewSet for Complaint Attachments""" queryset = ComplaintAttachment.objects.all() serializer_class = ComplaintAttachmentSerializer permission_classes = [IsAuthenticated] filterset_fields = ['complaint'] ordering = ['-created_at'] def get_queryset(self): queryset = super().get_queryset().select_related('complaint', 'uploaded_by') user = self.request.user # Filter based on complaint access if user.is_px_admin(): return queryset if user.is_hospital_admin() and user.hospital: return queryset.filter(complaint__hospital=user.hospital) if user.hospital: return queryset.filter(complaint__hospital=user.hospital) return queryset.none() class InquiryViewSet(viewsets.ModelViewSet): """ViewSet for Inquiries""" queryset = Inquiry.objects.all() serializer_class = InquirySerializer permission_classes = [IsAuthenticated] filterset_fields = ['status', 'category', 'source', 'hospital', 'department', 'assigned_to', 'hospital__organization'] search_fields = ['subject', 'message', 'contact_name', 'patient__mrn'] ordering_fields = ['created_at'] ordering = ['-created_at'] def perform_create(self, serializer): """Auto-set created_by from request.user""" inquiry = serializer.save(created_by=self.request.user) AuditService.log_from_request( event_type='inquiry_created', description=f"Inquiry created: {inquiry.subject}", request=self.request, content_object=inquiry, metadata={ 'created_by': str(inquiry.created_by.id) if inquiry.created_by else None } ) def get_queryset(self): """Filter inquiries based on user role""" queryset = super().get_queryset().select_related( 'patient', 'hospital', 'department', 'assigned_to', 'responded_by', 'created_by' ) user = self.request.user # PX Admins see all inquiries if user.is_px_admin(): return queryset # Source Users see ONLY inquiries THEY created if hasattr(user, 'source_user_profile') and user.source_user_profile.exists(): return queryset.filter(created_by=user) # Patients see ONLY their own inquiries (if they have user accounts) if hasattr(user, 'patient_profile'): return queryset.filter(patient__user=user) # Hospital Admins see inquiries for their hospital if user.is_hospital_admin() and user.hospital: return queryset.filter(hospital=user.hospital) # Department Managers see inquiries for their department if user.is_department_manager() and user.department: return queryset.filter(department=user.department) # Others see inquiries for their hospital if user.hospital: return queryset.filter(hospital=user.hospital) return queryset.none() @action(detail=True, methods=['post']) def respond(self, request, pk=None): """Respond to inquiry""" inquiry = self.get_object() response_text = request.data.get('response') if not response_text: return Response( {'error': 'response is required'}, status=status.HTTP_400_BAD_REQUEST ) inquiry.response = response_text inquiry.responded_at = timezone.now() inquiry.responded_by = request.user inquiry.status = 'resolved' inquiry.save() return Response({'message': 'Response submitted successfully'}) class ComplaintPRInteractionViewSet(viewsets.ModelViewSet): """ViewSet for PR Interactions""" queryset = ComplaintPRInteraction.objects.all() serializer_class = ComplaintPRInteractionSerializer permission_classes = [IsAuthenticated] filterset_fields = ['complaint', 'contact_method', 'procedure_explained', 'pr_staff'] ordering = ['-contact_date'] def get_queryset(self): queryset = super().get_queryset().select_related( 'complaint', 'pr_staff', 'created_by' ) user = self.request.user # Filter based on complaint access if user.is_px_admin(): return queryset if user.is_hospital_admin() and user.hospital: return queryset.filter(complaint__hospital=user.hospital) if user.hospital: return queryset.filter(complaint__hospital=user.hospital) return queryset.none() def perform_create(self, serializer): """Auto-set created_by from request.user""" interaction = serializer.save(created_by=self.request.user) # Create complaint update ComplaintUpdate.objects.create( complaint=interaction.complaint, update_type='note', message=f"PR Interaction recorded: Contact via {interaction.get_contact_method_display()}", created_by=self.request.user, metadata={ 'interaction_id': str(interaction.id), 'contact_method': interaction.contact_method, 'procedure_explained': interaction.procedure_explained } ) AuditService.log_from_request( event_type='pr_interaction_created', description=f"PR Interaction recorded for complaint: {interaction.complaint.title}", request=self.request, content_object=interaction, metadata={ 'complaint_id': str(interaction.complaint.id), 'contact_method': interaction.contact_method } ) class ComplaintMeetingViewSet(viewsets.ModelViewSet): """ViewSet for Complaint Meetings""" queryset = ComplaintMeeting.objects.all() serializer_class = ComplaintMeetingSerializer permission_classes = [IsAuthenticated] filterset_fields = ['complaint', 'meeting_type'] ordering = ['-meeting_date'] def get_queryset(self): queryset = super().get_queryset().select_related('complaint', 'created_by') user = self.request.user # Filter based on complaint access if user.is_px_admin(): return queryset if user.is_hospital_admin() and user.hospital: return queryset.filter(complaint__hospital=user.hospital) if user.hospital: return queryset.filter(complaint__hospital=user.hospital) return queryset.none() def perform_create(self, serializer): """Auto-set created_by from request.user""" meeting = serializer.save(created_by=self.request.user) # Create complaint update ComplaintUpdate.objects.create( complaint=meeting.complaint, update_type='note', message=f"Meeting recorded: {meeting.get_meeting_type_display()} - {meeting.outcome[:100] if meeting.outcome else ''}", created_by=self.request.user, metadata={ 'meeting_id': str(meeting.id), 'meeting_type': meeting.meeting_type } ) # If outcome is provided, consider it as resolution if meeting.outcome and meeting.complaint.status not in ['resolved', 'closed']: meeting.complaint.status = 'resolved' meeting.complaint.resolution = meeting.outcome meeting.complaint.resolved_at = timezone.now() meeting.complaint.resolved_by = self.request.user meeting.complaint.save(update_fields=['status', 'resolution', 'resolved_at', 'resolved_by']) # Create status update ComplaintUpdate.objects.create( complaint=meeting.complaint, update_type='status_change', message=f"Complaint resolved through meeting", created_by=self.request.user, old_status='in_progress', new_status='resolved' ) AuditService.log_from_request( event_type='meeting_created', description=f"Complaint Meeting recorded for: {meeting.complaint.title}", request=self.request, content_object=meeting, metadata={ 'complaint_id': str(meeting.complaint.id), 'meeting_type': meeting.meeting_type } ) # Public views (no authentication required) from django.shortcuts import render, redirect, get_object_or_404 from django.http import JsonResponse from django.views.decorators.http import require_GET from django.views.decorators.csrf import csrf_exempt def api_locations(request): """ API endpoint to get all locations for complaint form. Returns JSON list of all locations ordered by English name. Public endpoint (no authentication required). """ from apps.organizations.models import Location locations = Location.objects.all().order_by('name_en') locations_list = [ { 'id': loc.id, 'name': str(loc) # Uses __str__ which prefers English name } for loc in locations ] return JsonResponse({ 'success': True, 'locations': locations_list, 'count': len(locations_list) }) @require_GET def api_sections(request, location_id): """ API endpoint to get sections for a specific location. Returns JSON list of main sections that have subsections for given location. Public endpoint (no authentication required). """ from apps.organizations.models import MainSection, SubSection # Get available sections that have subsections for this location available_section_ids = SubSection.objects.filter( location_id=location_id ).values_list('main_section_id', flat=True).distinct() sections = MainSection.objects.filter( id__in=available_section_ids ).order_by('name_en') sections_list = [ { 'id': section.id, 'name': str(section) # Uses __str__ which prefers English name } for section in sections ] return JsonResponse({ 'success': True, 'location_id': location_id, 'sections': sections_list, 'count': len(sections_list) }) @require_GET def api_subsections(request, location_id, section_id): """ API endpoint to get subsections for a specific location and section. Returns JSON list of subsections for given location and section. Public endpoint (no authentication required). """ from apps.organizations.models import SubSection subsections = SubSection.objects.filter( location_id=location_id, main_section_id=section_id ).order_by('name_en') subsections_list = [ { 'id': sub.internal_id, # SubSection uses internal_id as primary key 'name': str(sub) # Uses __str__ which prefers English name } for sub in subsections ] return JsonResponse({ 'success': True, 'location_id': location_id, 'section_id': section_id, 'subsections': subsections_list, 'count': len(subsections_list) }) @require_GET def api_departments(request, hospital_id): """ API endpoint to get departments for a specific hospital. Returns JSON list of departments for given hospital. Public endpoint (no authentication required). """ from apps.organizations.models import Department departments = Department.objects.filter( hospital_id=hospital_id, status='active' ).order_by('name') departments_list = [ { 'id': dept.id, 'name': dept.name # Department model has 'name' field, not name_en } for dept in departments ] return JsonResponse({ 'success': True, 'hospital_id': hospital_id, 'departments': departments_list, 'count': len(departments_list) }) def complaint_explanation_form(request, complaint_id, token): """ Public-facing form for staff to submit explanation. This view does NOT require authentication. Validates token and checks if it's still valid (not used). """ from .models import ComplaintExplanation, ExplanationAttachment from apps.notifications.services import NotificationService from django.contrib.sites.shortcuts import get_current_site # Get complaint complaint = get_object_or_404(Complaint, id=complaint_id) # Validate token with staff and department prefetch # Also prefetch escalation relationship to show original staff explanation to manager explanation = get_object_or_404( ComplaintExplanation.objects.select_related( 'staff', 'staff__department', 'staff__report_to' ).prefetch_related('escalated_from_staff'), complaint=complaint, token=token ) # Get original staff explanation if this is an escalation original_explanation = None if hasattr(explanation, 'escalated_from_staff'): # This explanation was created as a result of escalation # Get the original staff explanation original_explanation = ComplaintExplanation.objects.filter( escalated_to_manager=explanation ).select_related('staff').first() # Check if token is already used if explanation.is_used: return render(request, 'complaints/explanation_already_submitted.html', { 'complaint': complaint, 'explanation': explanation }) if request.method == 'POST': # Handle form submission explanation_text = request.POST.get('explanation', '').strip() if not explanation_text: return render(request, 'complaints/explanation_form.html', { 'complaint': complaint, 'explanation': explanation, 'original_explanation': original_explanation, 'error': 'Please provide your explanation.' }) # Save explanation explanation.explanation = explanation_text explanation.is_used = True explanation.responded_at = timezone.now() explanation.save() # Handle file attachments files = request.FILES.getlist('attachments') for uploaded_file in files: ExplanationAttachment.objects.create( explanation=explanation, file=uploaded_file, filename=uploaded_file.name, file_type=uploaded_file.content_type, file_size=uploaded_file.size ) # Notify complaint assignee if complaint.assigned_to and complaint.assigned_to.email: site = get_current_site(request) complaint_url = f"https://{site.domain}/complaints/{complaint.id}/" subject = f"New Explanation Received - Complaint #{complaint.id}" email_body = f""" Dear {complaint.assigned_to.get_full_name()}, A new explanation has been submitted for the following complaint: COMPLAINT DETAILS: ---------------- Reference: #{complaint.id} Title: {complaint.title} Severity: {complaint.get_severity_display()} EXPLANATION SUBMITTED BY: ------------------------ {explanation.staff} EXPLANATION: ----------- {explanation.explanation} """ if files: email_body += f""" ATTACHMENTS: ------------ {len(files)} file(s) attached """ email_body += f""" To view the complaint and explanation, please visit: {complaint_url} --- This is an automated message from PX360 Complaint Management System. """ try: NotificationService.send_email( email=complaint.assigned_to.email, subject=subject, message=email_body, related_object=complaint, metadata={ 'notification_type': 'explanation_submitted', 'explanation_id': str(explanation.id), 'staff_id': str(explanation.staff.id) if explanation.staff else None } ) except Exception as e: # Log error but don't fail the submission import logging logger = logging.getLogger(__name__) logger.error(f"Failed to send notification email: {e}") # Create complaint update ComplaintUpdate.objects.create( complaint=complaint, update_type='communication', message=f"Explanation submitted by {explanation.staff}", metadata={ 'explanation_id': str(explanation.id), 'staff_id': str(explanation.staff.id) if explanation.staff else None } ) # Redirect to success page return render(request, 'complaints/explanation_success.html', { 'complaint': complaint, 'explanation': explanation, 'attachment_count': len(files) }) # GET request - display form return render(request, 'complaints/explanation_form.html', { 'complaint': complaint, 'explanation': explanation, 'original_explanation': original_explanation }) from django.http import HttpResponse def generate_complaint_pdf(request, pk): """ Generate PDF for a complaint using WeasyPrint. Creates a professionally styled PDF document with all complaint details including AI analysis, staff assignment, and resolution information. """ # Get complaint complaint = get_object_or_404(Complaint, id=pk) # Check permissions user = request.user if not user.is_authenticated: return HttpResponse('Unauthorized', status=401) # Check if user can view this complaint can_view = False if user.is_px_admin(): can_view = True elif user.is_hospital_admin() and user.hospital == complaint.hospital: can_view = True elif user.is_department_manager() and user.department == complaint.department: can_view = True elif user.hospital == complaint.hospital: can_view = True if not can_view: return HttpResponse('Forbidden', status=403) # Render HTML template with comprehensive data from django.template.loader import render_to_string # Get explanations with their acceptance status explanations = complaint.explanations.all().select_related('staff', 'accepted_by').prefetch_related('attachments') # Get timeline updates timeline = complaint.updates.all().select_related('created_by')[:20] # Limit to last 20 # Get related PX Actions from apps.px_action_center.models import PXAction from django.contrib.contenttypes.models import ContentType complaint_ct = ContentType.objects.get_for_model(Complaint) px_actions = PXAction.objects.filter( content_type=complaint_ct, object_id=complaint.id ).order_by('-created_at')[:5] html_string = render_to_string('complaints/complaint_pdf.html', { 'complaint': complaint, 'explanations': explanations, 'timeline': timeline, 'px_actions': px_actions, 'generated_at': timezone.now(), }) # Generate PDF using WeasyPrint try: from weasyprint import HTML pdf_file = HTML(string=html_string).write_pdf() # Create response response = HttpResponse(pdf_file, content_type='application/pdf') # Allow PDF to be displayed in iframe (same origin only) response['X-Frame-Options'] = 'SAMEORIGIN' # Check if view=inline is requested (for iframe display) view_mode = request.GET.get('view', 'download') if view_mode == 'inline': # Display inline in browser response['Content-Disposition'] = 'inline' else: # Download as attachment from datetime import datetime timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') filename = f"complaint_{complaint.reference_number}_{timestamp}.pdf" response['Content-Disposition'] = f'attachment; filename="{filename}"' # Log audit AuditService.log_from_request( event_type='pdf_generated', description=f"PDF generated for complaint: {complaint.title}", request=request, content_object=complaint, metadata={'complaint_id': str(pk)} ) return response except ImportError: return HttpResponse('WeasyPrint is not installed. Please install it to generate PDFs.', status=500) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Error generating PDF for complaint {pk}: {e}") return HttpResponse(f'Error generating PDF: {str(e)}', status=500)