import logging from django.utils import timezone from apps.core.services import AuditService from apps.notifications.services import NotificationService from apps.organizations.models import Department from apps.complaints.models import Complaint, ComplaintExplanation, ComplaintStatus, ComplaintUpdate logger = logging.getLogger(__name__) class ComplaintServiceError(Exception): pass class ComplaintService: @staticmethod def get_escalation_target(complaint, staff=None): """ Resolve an escalation target using a fallback chain. For explanation escalation (staff provided): staff.report_to -> staff.department.manager -> complaint.department.manager -> hospital admins & PX staff For complaint-level escalation (no staff): complaint.department.manager -> hospital admins & PX staff Args: complaint: Complaint instance staff: Optional Staff instance (the person being escalated from) Returns: tuple: (target_user, fallback_path) where target_user is a User (or None if no target found) and fallback_path describes which step succeeded. """ from apps.complaints.tasks import get_hospital_admins_and_staff if staff: if staff.report_to and staff.report_to.user and staff.report_to.user.is_active: return staff.report_to.user, "staff.report_to" staff_dept = getattr(staff, "department", None) if staff_dept and staff_dept.manager and staff_dept.manager.is_active: return staff_dept.manager, "staff.department.manager" if complaint.department and complaint.department.manager and complaint.department.manager.is_active: if not staff or (staff and getattr(staff, "department", None) != complaint.department): return complaint.department.manager, "complaint.department.manager" hospital = complaint.hospital if hospital: fallback = get_hospital_admins_and_staff(hospital).first() if fallback: return fallback, "hospital_admins_staff" return None, "no_target_found" @staticmethod def can_manage(user, complaint): if user.is_px_admin(): return True if user.is_hospital_admin() and user.hospital == complaint.hospital: return True if user.is_px_management() and user.hospital == complaint.hospital: return True if user.is_px_employee() and user.hospital == complaint.hospital: return True if user.is_department_manager() and user.department == complaint.department: return True if complaint.assigned_to and complaint.assigned_to == user: return True if complaint.involved_departments.filter(id=user.department_id).exists() if user.department_id else False: return True return False @staticmethod def can_activate(user, complaint): return ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or (user.is_department_manager() and complaint.department == user.department) or complaint.hospital == user.hospital ) @staticmethod def activate(complaint, user, request=None): if not complaint.is_active_status: raise ComplaintServiceError( f"Cannot activate complaint with status '{complaint.get_status_display()}'. " "Complaint must be Open, In Progress, or Partially Resolved." ) if not ComplaintService.can_activate(user, complaint): raise ComplaintServiceError("You don't have permission to activate this complaint.") if complaint.assigned_to == user: raise ComplaintServiceError("This complaint is already assigned to you.") previous_assignee = complaint.assigned_to old_status = complaint.status complaint.assigned_to = user complaint.assigned_at = timezone.now() if complaint.status == ComplaintStatus.OPEN: complaint.status = ComplaintStatus.IN_PROGRESS complaint.activated_at = timezone.now() complaint.save(update_fields=["assigned_to", "assigned_at", "status", "activated_at"]) else: complaint.save(update_fields=["assigned_to", "assigned_at"]) assign_message = f"Complaint activated and assigned to {user.get_full_name()}" if previous_assignee: assign_message += f" (reassigned from {previous_assignee.get_full_name()})" roles_display = ", ".join(user.get_role_names()) ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"{assign_message} ({roles_display})", created_by=user, metadata={ "old_assignee_id": str(previous_assignee.id) if previous_assignee else None, "new_assignee_id": str(user.id), "assignee_roles": user.get_role_names(), "old_status": old_status, "new_status": complaint.status, "activated_by_current_user": True, }, ) metadata = { "old_assignee_id": str(previous_assignee.id) if previous_assignee else None, "new_assignee_id": str(user.id), "old_status": old_status, "new_status": complaint.status, } if request: AuditService.log_from_request( event_type="complaint_activated", description=f"Complaint activated by {user.get_full_name()}", request=request, content_object=complaint, metadata=metadata, ) else: AuditService.log_event( event_type="complaint_activated", description=f"Complaint activated by {user.get_full_name()}", user=user, content_object=complaint, metadata=metadata, ) return { "success": True, "complaint": complaint, "old_assignee": previous_assignee, "old_status": old_status, } @staticmethod def assign(complaint, target_user, assigned_by, request=None): if not complaint.is_active_status and complaint.status not in ( ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED, ComplaintStatus.CANCELLED, ): raise ComplaintServiceError( f"Cannot assign complaint with status '{complaint.get_status_display()}'. " "Complaint must be in an active or terminal status." ) if not ( assigned_by.is_px_admin() or assigned_by.is_hospital_admin() or complaint.assigned_to == assigned_by ): raise ComplaintServiceError("You don't have permission to assign complaints.") old_assignee = complaint.assigned_to old_status = complaint.status complaint.assigned_to = target_user complaint.assigned_at = timezone.now() reopened = False if old_status in (ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED, ComplaintStatus.CANCELLED): complaint.status = ComplaintStatus.IN_PROGRESS complaint.resolved_at = None complaint.resolved_by = None complaint.closed_at = None complaint.closed_by = None complaint.reopened_at = timezone.now() complaint.reopened_by = assigned_by reopened = True complaint.save(update_fields=["assigned_to", "assigned_at", "status", "resolved_at", "resolved_by", "closed_at", "closed_by", "reopened_at", "reopened_by"]) else: complaint.save(update_fields=["assigned_to", "assigned_at"]) roles_display = ", ".join(target_user.get_role_names()) msg = f"Assigned to {target_user.get_full_name()} ({roles_display})" if old_assignee: msg += f" (reassigned from {old_assignee.get_full_name()})" if reopened: msg = f"Reopened and {msg}" ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=msg, created_by=assigned_by, metadata={ "old_assignee_id": str(old_assignee.id) if old_assignee else None, "new_assignee_id": str(target_user.id), "assignee_roles": target_user.get_role_names(), "reopened": reopened, "old_status": old_status, }, ) metadata = { "old_assignee_id": str(old_assignee.id) if old_assignee else None, "new_assignee_id": str(target_user.id), } if request: AuditService.log_from_request( event_type="assignment", description=f"Complaint assigned to {target_user.get_full_name()} ({roles_display})", request=request, content_object=complaint, metadata=metadata, ) else: AuditService.log_event( event_type="assignment", description=f"Complaint assigned to {target_user.get_full_name()}", user=assigned_by, content_object=complaint, metadata=metadata, ) return { "success": True, "complaint": complaint, "old_assignee": old_assignee, } # Valid status transitions for lifecycle enforcement VALID_STATUS_TRANSITIONS = { "open": ["in_progress", "cancelled", "contacted", "contacted_no_response"], "in_progress": ["partially_resolved", "resolved", "cancelled", "contacted", "contacted_no_response", "pending_external"], "partially_resolved": ["resolved", "in_progress", "cancelled", "pending_external"], "resolved": ["closed", "in_progress"], "closed": ["in_progress"], "cancelled": ["open", "in_progress"], "contacted": ["open", "in_progress", "contacted_no_response", "cancelled", "pending_external"], "contacted_no_response": ["open", "in_progress", "cancelled", "pending_external"], "pending_external": ["resolved", "in_progress", "cancelled", "closed"], } @staticmethod def reopen(complaint, reopened_by, request=None, *, note=""): if complaint.status not in ( ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED, ComplaintStatus.CANCELLED, ): raise ComplaintServiceError("Only resolved, closed, or cancelled complaints can be reopened.") old_status = complaint.status new_complaint = Complaint.objects.create( patient=complaint.patient, contact_name=complaint.contact_name, contact_phone=complaint.contact_phone, contact_email=complaint.contact_email, hospital=complaint.hospital, department=complaint.department, staff=complaint.staff, title=complaint.title, description=complaint.description, domain=complaint.domain, category=complaint.category, subcategory=complaint.subcategory, classification=complaint.classification, subcategory_obj=complaint.subcategory_obj, classification_obj=complaint.classification_obj, location=complaint.location, main_section=complaint.main_section, subsection=complaint.subsection, complaint_type=complaint.complaint_type, complaint_source_type=complaint.complaint_source_type, priority=complaint.priority, severity=complaint.severity, source=complaint.source, status=ComplaintStatus.OPEN, reopened_from=complaint, reopened_at=timezone.now(), reopened_by=reopened_by, ) ComplaintUpdate.objects.create( complaint=complaint, update_type="status_change", message=note or f"Complaint reopened as new complaint #{new_complaint.reference_number or new_complaint.id}", created_by=reopened_by, old_status=old_status, new_status=old_status, metadata={"reopened_as_new": True, "new_complaint_id": str(new_complaint.id)}, ) ComplaintUpdate.objects.create( complaint=new_complaint, update_type="status_change", message=f"Created as reopen of complaint #{complaint.reference_number or complaint.id}", created_by=reopened_by, old_status="", new_status=ComplaintStatus.OPEN, metadata={"reopened_from": str(complaint.id)}, ) metadata = { "old_status": old_status, "new_complaint_id": str(new_complaint.id), "original_complaint_id": str(complaint.id), } if request: AuditService.log_from_request( event_type="complaint_reopened", description=f"Complaint #{complaint.reference_number or complaint.id} reopened as new complaint by {reopened_by.get_full_name()}", request=request, content_object=new_complaint, metadata=metadata, ) else: AuditService.log_event( event_type="complaint_reopened", description=f"Complaint #{complaint.reference_number or complaint.id} reopened as new complaint by {reopened_by.get_full_name()}", user=reopened_by, content_object=new_complaint, metadata=metadata, ) return {"success": True, "complaint": new_complaint, "old_status": old_status, "original_complaint": complaint} @staticmethod def change_status( complaint, new_status, changed_by, request=None, *, note="", resolution="", resolution_outcome="", resolution_outcome_other="", resolution_category="", ): if not (changed_by.is_px_admin() or changed_by.is_hospital_admin()): raise ComplaintServiceError("You don't have permission to change complaint status.") if not new_status: raise ComplaintServiceError("Please select a status.") old_status = complaint.status # Enforce valid status transitions valid_next = ComplaintService.VALID_STATUS_TRANSITIONS.get(old_status, []) if new_status not in valid_next and not changed_by.is_px_admin(): raise ComplaintServiceError( f"Invalid status transition from '{old_status}' to '{new_status}'. " f"Allowed transitions: {', '.join(valid_next)}" ) complaint.status = new_status if new_status == ComplaintStatus.RESOLVED or new_status == "resolved": if complaint.was_pending_external and complaint.pending_external_set_at: complaint.resolved_at = complaint.pending_external_set_at else: complaint.resolved_at = timezone.now() complaint.resolved_by = changed_by if resolution: complaint.resolution = resolution complaint.resolution_sent_at = timezone.now() if resolution_category: complaint.resolution_category = resolution_category if resolution_outcome: complaint.resolution_outcome = resolution_outcome if resolution_outcome == "other" and resolution_outcome_other: complaint.resolution_outcome_other = resolution_outcome_other elif new_status == ComplaintStatus.CLOSED or new_status == "closed": complaint.closed_at = timezone.now() complaint.closed_by = changed_by from apps.complaints.tasks import send_complaint_resolution_survey send_complaint_resolution_survey.delay(str(complaint.id)) elif new_status == ComplaintStatus.PENDING_EXTERNAL or new_status == "pending_external": complaint.pending_external_set_at = timezone.now() complaint.was_pending_external = True complaint.save() ComplaintUpdate.objects.create( complaint=complaint, update_type="status_change", message=note or f"Status changed from {old_status} to {new_status}", created_by=changed_by, old_status=old_status, new_status=new_status, metadata={ "resolution_text": resolution if resolution else None, "resolution_category": resolution_category if resolution_category else None, }, ) metadata = { "old_status": old_status, "new_status": new_status, "resolution_category": resolution_category if resolution_category else None, } if request: 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=metadata, ) else: AuditService.log_event( event_type="status_change", description=f"Complaint status changed from {old_status} to {new_status}", user=changed_by, content_object=complaint, metadata=metadata, ) return { "success": True, "complaint": complaint, "old_status": old_status, "new_status": new_status, } @staticmethod def add_note(complaint, message, created_by, request=None): if not complaint.is_active_status: raise ComplaintServiceError( f"Cannot add notes to complaint with status '{complaint.get_status_display()}'. " "Complaint must be Open, In Progress, or Partially Resolved." ) if not message: raise ComplaintServiceError("Please enter a note.") update = ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=message, created_by=created_by, ) metadata = { "note_id": str(update.id), } if request: AuditService.log_from_request( event_type="note_added", description=f"Note added to complaint", request=request, content_object=complaint, metadata=metadata, ) else: AuditService.log_event( event_type="note_added", description=f"Note added to complaint", user=created_by, content_object=complaint, metadata=metadata, ) return update @staticmethod def change_department(complaint, department, changed_by, request=None): if not complaint.is_active_status: raise ComplaintServiceError( f"Cannot change department for complaint with status '{complaint.get_status_display()}'. " "Complaint must be Open, In Progress, or Partially Resolved." ) if not (changed_by.is_px_admin() or changed_by.is_hospital_admin()): raise ComplaintServiceError("You don't have permission to change complaint department.") if department.hospital != complaint.hospital: raise ComplaintServiceError("Department does not belong to this complaint's hospital.") old_department = complaint.department complaint.department = department complaint.save(update_fields=["department"]) ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Department changed to {department.name}", created_by=changed_by, metadata={ "old_department_id": str(old_department.id) if old_department else None, "new_department_id": str(department.id), }, ) metadata = { "old_department_id": str(old_department.id) if old_department else None, "new_department_id": str(department.id), } if request: AuditService.log_from_request( event_type="department_change", description=f"Complaint department changed to {department.name}", request=request, content_object=complaint, metadata=metadata, ) else: AuditService.log_event( event_type="department_change", description=f"Complaint department changed to {department.name}", user=changed_by, content_object=complaint, metadata=metadata, ) return { "success": True, "complaint": complaint, "old_department": old_department, } @staticmethod def send_to_department( complaint, department_groups, selected_dept_ids, request_message, requested_by, domain, request=None, ): import secrets if not complaint.is_active_status: raise ComplaintServiceError( f"Cannot send complaint to department with status '{complaint.get_status_display()}'. " "Complaint must be Open, In Progress, or Partially Resolved." ) champion_count = 0 skipped_no_email = 0 results = [] for dept_id, dept_info in department_groups.items(): if dept_id not in selected_dept_ids: continue champion = dept_info.get("champion") champion_email = dept_info.get("champion_email") if not champion or not champion_email: skipped_no_email += 1 continue staff_names = [s["staff_name"] for s in dept_info["staff_list"]] champion_token = secrets.token_urlsafe(32) explanation, created = ComplaintExplanation.objects.update_or_create( complaint=complaint, staff=champion, defaults={ "token": champion_token, "is_used": False, "requested_by": requested_by, "request_message": request_message, "email_sent_at": timezone.now(), "submitted_via": "email_link", }, ) champion_link = f"https://{domain}/complaints/{complaint.id}/explain/{champion_token}/" champion_subject = f"Explanation Request - Complaint #{complaint.reference_number}" champion_display = dept_info.get("champion_name", str(champion)) staff_list_text = "\n".join(f" - {n}" for n in staff_names) champion_email_body = f"""Dear {champion_display}, As the department champion for {dept_info['department_name']}, we are requesting your assistance in gathering explanations for a complaint involving staff from your department. INVOLVED STAFF FROM YOUR DEPARTMENT: ----------------------------------- {staff_list_text} COMPLAINT DETAILS: ---------------- Reference: {complaint.reference_number} Title: {complaint.title} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} {complaint.description or "No description provided."}""" if complaint.patient: champion_email_body += f""" PATIENT INFORMATION: ------------------ Name: {complaint.patient.get_full_name()} MRN: {complaint.patient.mrn or "N/A"}""" if request_message: champion_email_body += f""" ADDITIONAL MESSAGE: ------------------ {request_message}""" champion_email_body += f""" SUBMIT EXPLANATION: ------------------ Please coordinate with the involved staff and submit the explanation: {champion_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.""" try: NotificationService.send_email( email=champion_email, subject=champion_subject, message=champion_email_body, related_object=complaint, metadata={ "notification_type": "explanation_request", "staff_id": str(champion.id), "complaint_id": str(complaint.id), "department_id": dept_id, }, ) champion_count += 1 results.append( { "recipient_type": "champion", "recipient": champion_display, "email": champion_email, "department": dept_info["department_name"], "explanation_id": str(explanation.id), "sent": True, } ) except Exception as e: logger.error(f"Failed to send explanation request to champion {champion.id}: {e}") results.append( { "recipient_type": "champion", "recipient": champion_display, "email": champion_email, "department": dept_info["department_name"], "explanation_id": str(explanation.id), "sent": False, "error": str(e), } ) metadata = { "champion_count": champion_count, "skipped_no_email": skipped_no_email, "selected_dept_ids": selected_dept_ids, } if request: AuditService.log_from_request( event_type="explanation_requested", description=f"Explanation requests sent to {champion_count} department champions", request=request, content_object=complaint, metadata=metadata, ) else: AuditService.log_event( event_type="explanation_requested", description=f"Explanation requests sent to {champion_count} department champions", user=requested_by, content_object=complaint, metadata=metadata, ) if champion_count > 0: 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 department champions: {recipients_str}", created_by=requested_by, metadata={ "champion_count": champion_count, "results": results, }, ) complaint.status = ComplaintStatus.CONTACTED complaint.save(update_fields=["status", "updated_at"]) return { "champion_count": champion_count, "skipped_no_email": skipped_no_email, "results": results, } @staticmethod def post_create_hooks(complaint, created_by, request=None): from apps.complaints.tasks import analyze_complaint_with_ai, notify_admins_new_complaint ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message="Complaint created. AI analysis running in background.", created_by=created_by, ) analyze_complaint_with_ai.delay(str(complaint.id)) notify_admins_new_complaint.delay(str(complaint.id)) metadata = { "severity": complaint.severity, "patient_name": complaint.patient_name, "national_id": complaint.national_id, "hospital": complaint.hospital.name if complaint.hospital else None, "ai_analysis_pending": True, } if request: AuditService.log_from_request( event_type="complaint_created", description=f"Complaint created: {complaint.title}", request=request, content_object=complaint, metadata=metadata, ) else: AuditService.log_event( event_type="complaint_created", description=f"Complaint created: {complaint.title}", user=created_by, content_object=complaint, metadata=metadata, )