import logging from datetime import timedelta from django.utils import timezone from apps.core.services import AuditService from apps.notifications.services import NotificationService, get_email_header_html from apps.organizations.models import Department from apps.complaints.models import ( Complaint, ComplaintExplanation, ComplaintInvolvedDepartment, ComplaintStatus, ComplaintUpdate, ) logger = logging.getLogger(__name__) def _get_explanation_sla_config(hospital): """Get explanation SLA configuration for a hospital.""" from apps.complaints.models import ExplanationSLAConfig try: return ExplanationSLAConfig.objects.get(hospital=hospital, is_active=True) except ExplanationSLAConfig.DoesNotExist: return None 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 user.department_id and complaint.involved_departments.filter(department_id=user.department_id).exists(): 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() # Record first-activation timestamp regardless of starting status. # Previously this was only set in the OPEN branch, which left the workflow # stepper showing "not activated" for already-IN_PROGRESS complaints. first_activation = complaint.activated_at is None if first_activation: complaint.activated_at = timezone.now() if complaint.status == ComplaintStatus.OPEN: complaint.status = ComplaintStatus.IN_PROGRESS update_fields = ["assigned_to", "assigned_at", "status"] else: update_fields = ["assigned_to", "assigned_at"] if first_activation: update_fields.append("activated_at") complaint.save(update_fields=update_fields) 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.") # Complaints can only be assigned to Patient Experience team members. if not target_user.groups.filter( name__in=["PX Employee", "PX Admin", "PX Management"] ).exists(): raise ComplaintServiceError( "Complaints can only be assigned to Patient Experience team members." ) 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"], "in_progress": ["partially_resolved", "resolved", "cancelled", "pending_external", "ovr_pending"], "partially_resolved": ["resolved", "in_progress", "cancelled", "pending_external"], "resolved": ["closed", "in_progress"], "closed": ["in_progress"], "cancelled": ["open", "in_progress"], "pending_external": ["resolved", "in_progress", "cancelled", "closed"], "ovr_pending": ["in_progress", "resolved", "cancelled"], } @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, 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() or changed_by.is_px_management() or changed_by.is_px_employee()): raise ComplaintServiceError("You don't have permission to change complaint status.") if not new_status: raise ComplaintServiceError("Please select a status.") # Require activation before any status change other than closing the complaint. if not complaint.activated_at and new_status != ComplaintStatus.CLOSED and new_status != "closed": raise ComplaintServiceError("Complaint must be activated before changing its 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 elif new_status == ComplaintStatus.CANCELLED or new_status == "cancelled": complaint.cancelled_at = timezone.now() complaint.cancelled_by = changed_by elif new_status == ComplaintStatus.PARTIALLY_RESOLVED or new_status == "partially_resolved": complaint.partially_resolved_at = timezone.now() complaint.partially_resolved_by = changed_by 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.") # Cross-hospital isolation: only users from the same hospital can add notes if not created_by.is_px_admin() and complaint.hospital_id and created_by.hospital_id and \ complaint.hospital_id != created_by.hospital_id: raise ComplaintServiceError("You don't have permission to add notes to this complaint.") 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 update_location(complaint, *, location_type, area, department, section, changed_by, request=None, zone=None, floor=None): """Update the location-related fields of a complaint. Args: complaint: Complaint instance location_type: str (one of LocationType values, or "" to clear) area: Area instance or None department: Department instance or None section: Section instance or None changed_by: User performing the change request: HttpRequest (for audit logging) zone: str or None (free-text zone; None = leave unchanged, "" = clear) floor: str or None (floor; None = leave unchanged, "" = clear or default from dept) Any of the FK args may be None to clear the field. ``location_type`` may be "" to clear. Only fields whose value actually changes are written. If ``floor`` is empty AND a department is provided, the department's ``floor`` value is used as the default. """ if not complaint.is_active_status: raise ComplaintServiceError( f"Cannot update location 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() or changed_by.is_px_management() or changed_by.is_px_employee()): raise ComplaintServiceError("You don't have permission to update complaint location.") if area is not None and area.hospital_id != complaint.hospital_id: raise ComplaintServiceError("Area does not belong to this complaint's hospital.") if department is not None and department.hospital_id != complaint.hospital_id: raise ComplaintServiceError("Department does not belong to this complaint's hospital.") if section is not None and department is not None and section.department_id != department.id: raise ComplaintServiceError("Section does not belong to the selected department.") # Floor default-from-department: an empty floor falls back to the department's floor. if floor is not None and not floor.strip() and department is not None and department.floor: floor = department.floor update_fields = [] changes = [] old_location_type = complaint.location_type if location_type != old_location_type: complaint.location_type = location_type update_fields.append("location_type") changes.append(("location_type", old_location_type, location_type)) old_area = complaint.area if area != old_area: complaint.area = area update_fields.append("area") changes.append(("area", str(old_area.id) if old_area else None, str(area.id) if area else None)) old_department = complaint.department if department != old_department: complaint.department = department update_fields.append("department") changes.append(("department", str(old_department.id) if old_department else None, str(department.id) if department else None)) # If department changed and the current section no longer matches, clear it. if complaint.section is not None and department is not None and complaint.section.department_id != department.id: old_section = complaint.section complaint.section = None update_fields.append("section") changes.append(("section", str(old_section.id), None)) elif section is not None and section != complaint.section: old_section = complaint.section complaint.section = section update_fields.append("section") changes.append(("section", str(old_section.id) if old_section else None, str(section.id) if section else None)) elif section is None and complaint.section is not None: old_section = complaint.section complaint.section = None update_fields.append("section") changes.append(("section", str(old_section.id), None)) if zone is not None and zone != complaint.zone: old_zone = complaint.zone complaint.zone = zone update_fields.append("zone") changes.append(("zone", old_zone, zone)) if floor is not None and floor != complaint.floor: old_floor = complaint.floor complaint.floor = floor update_fields.append("floor") changes.append(("floor", old_floor, floor)) if not update_fields: return {"success": True, "complaint": complaint, "changes": []} complaint.save(update_fields=update_fields) change_summary = ", ".join( f"{field}: {('cleared' if not new else new)}" for field, old, new in changes ) ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Location details updated ({change_summary}).", created_by=changed_by, metadata={"changes": {field: {"old": old, "new": new} for field, old, new in changes}}, ) metadata = {"changes": {field: {"old": old, "new": new} for field, old, new in changes}} if request: AuditService.log_from_request( event_type="location_update", description=f"Complaint location details updated ({change_summary}).", request=request, content_object=complaint, metadata=metadata, ) else: AuditService.log_event( event_type="location_update", description=f"Complaint location details updated ({change_summary}).", user=changed_by, content_object=complaint, metadata=metadata, ) return {"success": True, "complaint": complaint, "changes": changes} @staticmethod def send_to_department( complaint, department_groups, selected_dept_ids, request_message, requested_by, domain, request=None, contact_person_map=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 = None champion_email = None champion_display = None if contact_person_map and dept_id in contact_person_map: cp_id = contact_person_map[dept_id] dept_obj = Department.objects.filter(id=dept_id).first() if dept_obj: cinfo = dept_obj.is_valid_contact_person(cp_id) if cinfo: champion = cinfo["staff"] champion_email = cinfo["email"] champion_display = f"{cinfo['name']} ({cinfo['role_label']})" if not champion or not champion_email: champion = dept_info.get("champion") champion_email = dept_info.get("champion_email") if not champion or not champion_email: dept_obj = Department.objects.filter(id=dept_id).select_related("manager", "manager__staff_profile").first() if dept_obj and dept_obj.manager: manager_staff = getattr(dept_obj.manager, 'staff_profile', None) if manager_staff: champion = manager_staff champion_email = manager_staff.email or dept_obj.manager.email if not champion: 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 = champion_display or 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.get_full_name()}, We are requesting your assistance in gathering explanations for a complaint involving staff from {dept_info['department_name']}. 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, html_message=f"""
""", 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), } ) # Set SLA due date on each created explanation now = timezone.now() sla_config = _get_explanation_sla_config(complaint.hospital) sla_hours = sla_config.response_hours if sla_config else 48 for result in results: if result.get("sent") and result.get("explanation_id"): ComplaintExplanation.objects.filter(id=result["explanation_id"]).update( sla_due_at=now + timedelta(hours=sla_hours) ) # Mark complaint as sent to department and set forwarded timestamp if champion_count > 0: complaint.sent_to_department = True complaint.sent_to_department_at = complaint.sent_to_department_at or now complaint.forwarded_to_dept_at = complaint.forwarded_to_dept_at or now complaint.explanation_requested = True complaint.explanation_requested_at = complaint.explanation_requested_at or now # Create/update ComplaintInvolvedDepartment records for each selected dept from apps.complaints.models import ComplaintInvolvedDepartment as CID for dept_id in selected_dept_ids: dept_info = department_groups.get(dept_id) if not dept_info: continue try: dept = Department.objects.get(id=dept_id) except Department.DoesNotExist: continue inv_dept, created = CID.objects.get_or_create( complaint=complaint, department=dept, defaults={ "role": "secondary", "added_by": requested_by, "sent": True, "sent_at": now, "forwarded_at": now, }, ) if not created and not inv_dept.sent: inv_dept.sent = True inv_dept.sent_at = inv_dept.sent_at or now inv_dept.forwarded_at = inv_dept.forwarded_at or now inv_dept.save(update_fields=["sent", "sent_at", "forwarded_at"]) # If re-sending to a department that previously rejected the routing, # reset the rejection so the champion can act again. if not created and inv_dept.routing_status == ComplaintInvolvedDepartment.RoutingStatus.REJECTED: inv_dept.routing_status = ComplaintInvolvedDepartment.RoutingStatus.SENT inv_dept.rejected_at = None inv_dept.rejected_by_staff = None inv_dept.rejection_reason = "" inv_dept.suggested_department = None inv_dept.save(update_fields=[ "routing_status", "rejected_at", "rejected_by_staff", "rejection_reason", "suggested_department", ]) 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.save(update_fields=[ "updated_at", "sent_to_department", "sent_to_department_at", "forwarded_to_dept_at", "explanation_requested", "explanation_requested_at", ]) return { "champion_count": champion_count, "skipped_no_email": skipped_no_email, "results": results, "manager_count": 0, } @staticmethod def ensure_involved_records(complaint): """Ensure the complaint's primary staff exists as an involved record. Called lazily from the complaint detail view. Uses get_or_create so it's idempotent — only creates records that don't exist yet. Note: Department involvement is NOT auto-created. The AI suggestion lives on complaint.department itself; the user must explicitly confirm it via the "Confirm" button in the Departments tab (confirm_ai_department_suggestion view) or add a different department via the "Add" button. This prevents AI guesses (often "Patient Experience" for vague complaints) from silently becoming the primary involved department. Skips recreation when the user has explicitly removed the primary staff involvement (tracked via primary_staff_involved_removed). If complaint.staff later changes to a different record, auto-recreation resumes. """ from apps.complaints.models import ComplaintInvolvedStaff if complaint.staff_id and complaint.primary_staff_involved_removed_id != complaint.staff_id: ComplaintInvolvedStaff.objects.get_or_create( complaint=complaint, staff=complaint.staff, defaults={ "role": "accused", }, ) @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, ) class RoutingRejectionError(Exception): """Raised when a routing rejection is not allowed.""" def reject_department_routing(involved_dept, *, staff=None, user=None, reason="", suggested_department=None): """ Mark a ComplaintInvolvedDepartment as rejected (wrong department). Shared between the no-login token view and the logged-in champion view. - Marks routing_status=REJECTED, sets rejected_at / rejected_by_staff / rejection_reason / suggested_department. - If the rejected department is the complaint's primary, clears complaint.department / complaint.section so PX can reassign. - Creates a ComplaintUpdate timeline entry + an AuditEvent. - Notifies the complaint handler (complaint.assigned_to) and PX admins by email. Returns the updated involved_dept. Raises RoutingRejectionError if the routing cannot be rejected. """ if not involved_dept.can_reject_routing: raise RoutingRejectionError("This routing can no longer be rejected.") complaint = involved_dept.complaint department = involved_dept.department now = timezone.now() involved_dept.routing_status = ComplaintInvolvedDepartment.RoutingStatus.REJECTED involved_dept.rejected_at = now involved_dept.rejected_by_staff = staff involved_dept.rejection_reason = reason involved_dept.suggested_department = suggested_department involved_dept.save(update_fields=[ "routing_status", "rejected_at", "rejected_by_staff", "rejection_reason", "suggested_department", ]) was_primary = involved_dept.is_primary or complaint.department_id == department.pk if was_primary and complaint.department_id == department.pk: complaint.department = None complaint.section = None complaint.save(update_fields=["department", "section"]) actor_label = staff.get_full_name() if staff else (user.get_full_name() if user else "Champion") suggested_label = "" if suggested_department is not None: suggested_label = f" Suggested department: {suggested_department.get_localized_name()}." ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=( f"{department.get_localized_name()} rejected the routing " f"(wrong department). Reason: {reason or 'No reason provided.'}.{suggested_label}" ), created_by=user, metadata={ "department_id": str(department.id), "staff_id": str(staff.id) if staff else None, "reason": reason, "suggested_department_id": str(suggested_department.id) if suggested_department else None, "was_primary": was_primary, }, ) AuditService.log_event( event_type="dept_routing_rejected", description=( f"{actor_label} rejected routing of complaint {complaint.reference_number} " f"to {department.get_localized_name()} (wrong department)." ), user=user, content_object=complaint, metadata={ "department_id": str(department.id), "reason": reason, "suggested_department_id": str(suggested_department.id) if suggested_department else None, "was_primary": was_primary, }, ) _notify_routing_rejected( complaint=complaint, department=department, reason=reason, suggested_label=suggested_label, actor_label=actor_label, ) return involved_dept def _notify_routing_rejected(*, complaint, department, reason, suggested_label, actor_label): """Email + in-app notification to the complaint handler and PX admins.""" subject = f"Department Rejected Routing - {complaint.reference_number}" plain = ( f"{department.get_localized_name()} has rejected the routing of complaint " f"{complaint.reference_number} as it was sent to the wrong department.\n\n" f"Rejected by: {actor_label}\n" f"Reason: {reason or 'No reason provided.'}{suggested_label}\n\n" f"Please review and re-route to the correct department.\n" ) html = f""" """ recipients = [] if complaint.assigned_to and complaint.assigned_to.email: recipients.append(complaint.assigned_to) for recipient in recipients: try: NotificationService.send_email( email=recipient.email, subject=subject, message=plain, html_message=html, related_object=complaint, user=recipient, notification_type="dept_routing_rejected", ) except Exception: logger.exception("Failed to send routing-rejected notification for complaint %s", complaint.id)