HH/apps/complaints/services/complaint_service.py
2026-07-05 14:49:45 +03:00

1202 lines
48 KiB
Python

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()
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"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Explanation Request - Complaint #{complaint.reference_number}</h2>
<p style="margin: 0 0 12px 0;">Dear {champion.get_full_name()},</p>
<p style="margin: 0 0 12px 0;">We are requesting your assistance in gathering explanations for a complaint involving staff from <strong>{dept_info['department_name']}</strong>.</p>
<p style="margin: 0 0 12px 0;">Please coordinate with the involved staff and submit the explanation.</p>
<div style="text-align: center; margin: 20px 0;">
<a href="{champion_link}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Submit Explanation</a>
</div>
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">This link can only be used once. After submission, it will expire.</p>
</div>
</div>
""",
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"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Department Rejected Routing</h2>
<p style="margin: 0 0 8px 0;"><strong>{department.get_localized_name()}</strong> has rejected the routing of
complaint <strong>#{complaint.reference_number}</strong> as it was sent to the wrong department.</p>
<p style="margin: 0 0 4px 0;"><strong>Rejected by:</strong> {actor_label}</p>
<p style="margin: 0 0 4px 0;"><strong>Reason:</strong> {reason or 'No reason provided.'}{suggested_label}</p>
<p style="margin: 12px 0 0 0; color: #6b7280; font-size: 13px;">Please review and re-route to the correct department.</p>
</div>
</div>
"""
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)