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, 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() 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"], "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.") 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"""
{get_email_header_html()}

Explanation Request - Complaint #{complaint.reference_number}

Dear {champion.get_full_name()},

We are requesting your assistance in gathering explanations for a complaint involving staff from {dept_info['department_name']}.

Please coordinate with the involved staff and submit the explanation.

Submit Explanation

This link can only be used once. After submission, it will expire.

""", 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() 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, )