481 lines
16 KiB
Python
481 lines
16 KiB
Python
"""
|
|
Complaints views and viewsets
|
|
"""
|
|
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', 'physician', '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', 'physician',
|
|
'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=['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)
|
|
|
|
|
|
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'})
|