HH/apps/complaints/views.py

891 lines
31 KiB
Python

"""
Complaints views and viewsets
"""
from django.db.models import Q
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, ComplaintUpdate, Inquiry
from .serializers import (
ComplaintAttachmentSerializer,
ComplaintListSerializer,
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', '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'
).prefetch_related('attachments', 'updates')
user = self.request.user
# PX Admins see all complaints
if user.is_px_admin():
return queryset
# 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 perform_create(self, serializer):
"""Log complaint creation and trigger resolution satisfaction survey"""
complaint = serializer.save()
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
}
)
# TODO: Optionally create PX Action (Phase 6)
# from apps.complaints.tasks import create_action_from_complaint
# create_action_from_complaint.delay(str(complaint.id))
@action(detail=True, methods=['post'])
def assign(self, request, pk=None):
"""Assign complaint to user"""
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)
complaint.assigned_to = user
complaint.assigned_at = timezone.now()
complaint.save(update_fields=['assigned_to', 'assigned_at'])
# Create update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='assignment',
message=f"Assigned to {user.get_full_name()}",
created_by=request.user
)
AuditService.log_from_request(
event_type='assignment',
description=f"Complaint assigned to {user.get_full_name()}",
request=request,
content_object=complaint
)
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=['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', '')
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
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
)
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}
)
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 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
complaint.save(update_fields=['staff'])
# 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 from AI-suggested action"""
complaint = self.get_object()
# Check if complaint has suggested action
suggested_action = request.data.get('suggested_action')
if not suggested_action and complaint.metadata and 'ai_analysis' in complaint.metadata:
suggested_action = complaint.metadata['ai_analysis'].get('suggested_action_en')
if not suggested_action:
return Response(
{'error': 'No suggested action available for this complaint'},
status=status.HTTP_400_BAD_REQUEST
)
# Get category (optional - will be auto-mapped from complaint category if not provided)
category = request.data.get('category')
# If category not provided, auto-map from complaint category
if not category:
if complaint.category:
category = map_complaint_category_to_action_category(complaint.category.code)
else:
category = 'other'
# Validate category choice if manually provided
valid_categories = [
'clinical_quality', 'patient_safety', 'service_quality',
'staff_behavior', 'facility', 'process_improvement', 'other'
]
if category not in valid_categories:
return Response(
{'error': f'Invalid category. Valid options: {", ".join(valid_categories)}'},
status=status.HTTP_400_BAD_REQUEST
)
# Get optional assigned_to
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=f"Action from Complaint: {complaint.title}",
description=suggested_action,
hospital=complaint.hospital,
department=complaint.department,
category=category,
priority=complaint.priority,
severity=complaint.severity,
assigned_to=assigned_to,
status='open',
metadata={
'source_complaint_id': str(complaint.id),
'source_complaint_title': complaint.title,
'ai_generated': True,
'created_from_ai_suggestion': True
}
)
# Create action log entry
PXActionLog.objects.create(
action=action,
log_type='note',
message=f"Action created from AI-suggested action for complaint: {complaint.title}",
created_by=request.user,
metadata={
'complaint_id': str(complaint.id),
'ai_generated': True
}
)
# Create complaint update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='note',
message=f"PX Action created from AI-suggested action (Action #{action.id})",
created_by=request.user,
metadata={'action_id': str(action.id)}
)
# Log audit
AuditService.log_from_request(
event_type='action_created_from_ai',
description=f"PX Action created from AI-suggested action for complaint: {complaint.title}",
request=request,
content_object=action,
metadata={
'complaint_id': str(complaint.id),
'category': category,
'ai_generated': True
}
)
return Response({
'action_id': str(action.id),
'message': 'Action created successfully from AI-suggested action'
}, 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.
"""
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
recipient = None
recipient_display = None
recipient_type = None
# Priority 1: Staff member mentioned in complaint
if complaint.staff and complaint.staff.user:
recipient = complaint.staff.user
recipient_display = complaint.staff.get_full_name()
recipient_type = 'Staff Member'
# Priority 2: Department head
elif complaint.department and complaint.department.manager:
recipient = complaint.department.manager
recipient_display = recipient.get_full_name()
recipient_type = 'Department Head'
# Check if we found a recipient
if not recipient or not recipient.email:
return Response(
{'error': 'No valid recipient found. Complaint must have either a staff member with user account, 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.get_full_name()},
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),
'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),
'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),
'recipient_email': recipient.email
}
)
return Response({
'success': True,
'message': 'Email notification sent successfully',
'recipient': recipient_display,
'recipient_type': recipient_type,
'recipient_email': recipient.email
})
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', 'hospital', 'department', 'assigned_to', 'hospital__organization']
search_fields = ['subject', 'message', 'contact_name', 'patient__mrn']
ordering_fields = ['created_at']
ordering = ['-created_at']
def get_queryset(self):
"""Filter inquiries based on user role"""
queryset = super().get_queryset().select_related(
'patient', 'hospital', 'department', 'assigned_to', 'responded_by'
)
user = self.request.user
# PX Admins see all inquiries
if user.is_px_admin():
return queryset
# 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'})