HH/apps/complaints/services/complaint_service.py
ismail c5f76b3855
Some checks are pending
Build and Push Docker Image / build (push) Waiting to run
updates
2026-05-11 14:45:30 +03:00

777 lines
29 KiB
Python

import logging
from django.utils import timezone
from apps.core.services import AuditService
from apps.notifications.services import NotificationService
from apps.organizations.models import Department
from apps.complaints.models import Complaint, ComplaintExplanation, ComplaintStatus, ComplaintUpdate
logger = logging.getLogger(__name__)
class ComplaintServiceError(Exception):
pass
class ComplaintService:
@staticmethod
def get_escalation_target(complaint, staff=None):
"""
Resolve an escalation target using a fallback chain.
For explanation escalation (staff provided):
staff.report_to -> staff.department.manager ->
complaint.department.manager -> hospital admins & PX 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 complaint.involved_departments.filter(id=user.department_id).exists() if user.department_id else False:
return True
return False
@staticmethod
def can_activate(user, complaint):
return (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_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", "contacted", "contacted_no_response"],
"in_progress": ["partially_resolved", "resolved", "cancelled", "contacted", "contacted_no_response", "pending_external"],
"partially_resolved": ["resolved", "in_progress", "cancelled", "pending_external"],
"resolved": ["closed", "in_progress"],
"closed": ["in_progress"],
"cancelled": ["open", "in_progress"],
"contacted": ["open", "in_progress", "contacted_no_response", "cancelled", "pending_external"],
"contacted_no_response": ["open", "in_progress", "cancelled", "pending_external"],
"pending_external": ["resolved", "in_progress", "cancelled", "closed"],
}
@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,
location=complaint.location,
main_section=complaint.main_section,
subsection=complaint.subsection,
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()):
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
complaint.save()
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="status_change",
message=note or f"Status changed from {old_status} to {new_status}",
created_by=changed_by,
old_status=old_status,
new_status=new_status,
metadata={
"resolution_text": resolution if resolution else None,
"resolution_category": resolution_category if resolution_category else None,
},
)
metadata = {
"old_status": old_status,
"new_status": new_status,
"resolution_category": resolution_category if resolution_category else None,
}
if request:
AuditService.log_from_request(
event_type="status_change",
description=f"Complaint status changed from {old_status} to {new_status}",
request=request,
content_object=complaint,
metadata=metadata,
)
else:
AuditService.log_event(
event_type="status_change",
description=f"Complaint status changed from {old_status} to {new_status}",
user=changed_by,
content_object=complaint,
metadata=metadata,
)
return {
"success": True,
"complaint": complaint,
"old_status": old_status,
"new_status": new_status,
}
@staticmethod
def add_note(complaint, message, created_by, request=None):
if not complaint.is_active_status:
raise ComplaintServiceError(
f"Cannot add notes to complaint with status '{complaint.get_status_display()}'. "
"Complaint must be Open, In Progress, or Partially Resolved."
)
if not message:
raise ComplaintServiceError("Please enter a note.")
update = ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=message,
created_by=created_by,
)
metadata = {
"note_id": str(update.id),
}
if request:
AuditService.log_from_request(
event_type="note_added",
description=f"Note added to complaint",
request=request,
content_object=complaint,
metadata=metadata,
)
else:
AuditService.log_event(
event_type="note_added",
description=f"Note added to complaint",
user=created_by,
content_object=complaint,
metadata=metadata,
)
return update
@staticmethod
def change_department(complaint, department, changed_by, request=None):
if not complaint.is_active_status:
raise ComplaintServiceError(
f"Cannot change department for complaint with status '{complaint.get_status_display()}'. "
"Complaint must be Open, In Progress, or Partially Resolved."
)
if not (changed_by.is_px_admin() or changed_by.is_hospital_admin()):
raise ComplaintServiceError("You don't have permission to change complaint department.")
if department.hospital != complaint.hospital:
raise ComplaintServiceError("Department does not belong to this complaint's hospital.")
old_department = complaint.department
complaint.department = department
complaint.save(update_fields=["department"])
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="assignment",
message=f"Department changed to {department.name}",
created_by=changed_by,
metadata={
"old_department_id": str(old_department.id) if old_department else None,
"new_department_id": str(department.id),
},
)
metadata = {
"old_department_id": str(old_department.id) if old_department else None,
"new_department_id": str(department.id),
}
if request:
AuditService.log_from_request(
event_type="department_change",
description=f"Complaint department changed to {department.name}",
request=request,
content_object=complaint,
metadata=metadata,
)
else:
AuditService.log_event(
event_type="department_change",
description=f"Complaint department changed to {department.name}",
user=changed_by,
content_object=complaint,
metadata=metadata,
)
return {
"success": True,
"complaint": complaint,
"old_department": old_department,
}
@staticmethod
def send_to_department(
complaint,
department_groups,
selected_dept_ids,
request_message,
requested_by,
domain,
request=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 = dept_info.get("champion")
champion_email = dept_info.get("champion_email")
if not champion or not champion_email:
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 = 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_display},
As the department champion for {dept_info['department_name']}, we are requesting your assistance in gathering explanations for a complaint involving staff from your department.
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,
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),
}
)
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.status = ComplaintStatus.CONTACTED
complaint.save(update_fields=["status", "updated_at"])
return {
"champion_count": champion_count,
"skipped_no_email": skipped_no_email,
"results": results,
}
@staticmethod
def post_create_hooks(complaint, created_by, request=None):
from apps.complaints.tasks import analyze_complaint_with_ai, notify_admins_new_complaint
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message="Complaint created. AI analysis running in background.",
created_by=created_by,
)
analyze_complaint_with_ai.delay(str(complaint.id))
notify_admins_new_complaint.delay(str(complaint.id))
metadata = {
"severity": complaint.severity,
"patient_name": complaint.patient_name,
"national_id": complaint.national_id,
"hospital": complaint.hospital.name if complaint.hospital else None,
"ai_analysis_pending": True,
}
if request:
AuditService.log_from_request(
event_type="complaint_created",
description=f"Complaint created: {complaint.title}",
request=request,
content_object=complaint,
metadata=metadata,
)
else:
AuditService.log_event(
event_type="complaint_created",
description=f"Complaint created: {complaint.title}",
user=created_by,
content_object=complaint,
metadata=metadata,
)