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 coordinators For complaint-level escalation (no staff): complaint.department.manager -> hospital admins & PX coordinators 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_coordinators 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_coordinators(hospital).first() if fallback: return fallback, "hospital_admins_coordinators" 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_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_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: raise ComplaintServiceError( f"Cannot assign complaint with status '{complaint.get_status_display()}'. " "Complaint must be Open, In Progress, or Partially Resolved." ) if not (assigned_by.is_px_admin() or assigned_by.is_hospital_admin()): raise ComplaintServiceError("You don't have permission to assign complaints.") old_assignee = complaint.assigned_to complaint.assigned_to = target_user complaint.assigned_at = timezone.now() complaint.save(update_fields=["assigned_to", "assigned_at"]) roles_display = ", ".join(target_user.get_role_names()) ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Assigned to {target_user.get_full_name()} ({roles_display})", 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(), }, ) 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, } @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 complaint.status = new_status if new_status == ComplaintStatus.RESOLVED or new_status == "resolved": 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)) 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 request_explanation( complaint, staff_list, selected_staff_ids, selected_manager_ids, request_message, requested_by, domain, request=None, ): import secrets if not complaint.is_active_status: raise ComplaintServiceError( f"Cannot request explanation for complaint with status '{complaint.get_status_display()}'. " "Complaint must be Open, In Progress, or Partially Resolved." ) staff_count = 0 manager_count = 0 skipped_no_email = 0 results = [] notified_managers = set() for recipient in staff_list: staff = recipient["staff"] staff_id = recipient["staff_id"] if staff_id not in selected_staff_ids: continue staff_email = recipient.get("staff_email") if not staff_email: skipped_no_email += 1 continue staff_token = secrets.token_urlsafe(32) explanation, created = ComplaintExplanation.objects.update_or_create( complaint=complaint, staff=staff, defaults={ "token": staff_token, "is_used": False, "requested_by": requested_by, "request_message": request_message, "email_sent_at": timezone.now(), "submitted_via": "email_link", }, ) staff_link = f"https://{domain}/complaints/{complaint.id}/explain/{staff_token}/" staff_subject = f"Explanation Request - Complaint #{complaint.reference_number}" staff_display = recipient.get("staff_name", str(staff)) staff_email_body = f"""Dear {staff_display}, We have received a complaint that requires your explanation. 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: staff_email_body += f""" PATIENT INFORMATION: ------------------ Name: {complaint.patient.get_full_name()} MRN: {complaint.patient.mrn or "N/A"}""" if request_message: staff_email_body += f""" ADDITIONAL MESSAGE: ------------------ {request_message}""" staff_email_body += f""" SUBMIT YOUR EXPLANATION: ------------------------ 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.""" try: NotificationService.send_email( email=staff_email, subject=staff_subject, message=staff_email_body, related_object=complaint, metadata={ "notification_type": "explanation_request", "staff_id": str(staff.id), "complaint_id": str(complaint.id), }, ) staff_count += 1 results.append( { "recipient_type": "staff", "recipient": staff_display, "email": staff_email, "explanation_id": str(explanation.id), "sent": True, } ) except Exception as e: logger.error(f"Failed to send explanation request to staff {staff.id}: {e}") results.append( { "recipient_type": "staff", "recipient": staff_display, "email": staff_email, "explanation_id": str(explanation.id), "sent": False, "error": str(e), } ) manager = recipient.get("manager") manager_id = recipient.get("manager_id") if manager and manager_id and manager_id in selected_manager_ids: if manager.id not in notified_managers: manager_email = recipient.get("manager_email") if manager_email: manager_display = recipient.get("manager_name", str(manager)) manager_subject = f"Staff Explanation Requested - Complaint #{complaint.reference_number}" manager_email_body = f"""Dear {manager_display}, This is an informational notification that an explanation has been requested from a staff member who reports to you. STAFF MEMBER: ------------ Name: {staff_display} Department: {recipient.get("department", "")} Role in Complaint: {recipient.get("role", "")} COMPLAINT DETAILS: ---------------- Reference: {complaint.reference_number} Title: {complaint.title} Severity: {complaint.get_severity_display()} The staff member has been sent a link to submit their explanation. You will be notified when they respond. 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=manager_email, subject=manager_subject, message=manager_email_body, related_object=complaint, metadata={ "notification_type": "explanation_request_manager_notification", "manager_id": str(manager.id), "staff_id": str(staff.id), "complaint_id": str(complaint.id), }, ) manager_count += 1 notified_managers.add(manager.id) except Exception as e: logger.error(f"Failed to send manager notification to {manager.id}: {e}") metadata = { "staff_count": staff_count, "manager_count": manager_count, "skipped_no_email": skipped_no_email, "selected_staff_ids": selected_staff_ids, "selected_manager_ids": selected_manager_ids, } if request: AuditService.log_from_request( event_type="explanation_requested", description=f"Explanation requests sent to {staff_count} staff and {manager_count} managers", request=request, content_object=complaint, metadata=metadata, ) else: AuditService.log_event( event_type="explanation_requested", description=f"Explanation requests sent to {staff_count} staff and {manager_count} managers", user=requested_by, content_object=complaint, metadata=metadata, ) if staff_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: {recipients_str}", created_by=requested_by, metadata={ "staff_count": staff_count, "manager_count": manager_count, "results": results, }, ) complaint.status = ComplaintStatus.CONTACTED complaint.save(update_fields=["status", "updated_at"]) return { "staff_count": staff_count, "manager_count": manager_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, )