1202 lines
48 KiB
Python
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)
|
|
|