2775 lines
102 KiB
Python
2775 lines
102 KiB
Python
"""
|
|
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,
|
|
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'
|
|
]:
|
|
# 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 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 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 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 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 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
|
|
explanation = get_object_or_404(ComplaintExplanation, complaint=complaint, token=token)
|
|
|
|
# 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,
|
|
'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
|
|
})
|
|
|
|
|
|
from django.http import HttpResponse
|
|
|
|
|
|
def generate_complaint_pdf(request, complaint_id):
|
|
"""
|
|
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=complaint_id)
|
|
|
|
# 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
|
|
from django.template.loader import render_to_string
|
|
html_string = render_to_string('complaints/complaint_pdf.html', {
|
|
'complaint': complaint,
|
|
})
|
|
|
|
# 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')
|
|
filename = f"complaint_{complaint.id.strftime('%Y%m%d_%H%M%S')}.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(complaint.id)}
|
|
)
|
|
|
|
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 {complaint.id}: {e}")
|
|
return HttpResponse(f'Error generating PDF: {str(e)}', status=500)
|