HH/apps/complaints/services/complaint_service.py
2026-03-28 14:03:56 +03:00

676 lines
24 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 coordinators
For complaint-level escalation (no staff):
complaint.department.manager -> hospital admins & PX coordinators
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_coordinators
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_coordinators(hospital).first()
if fallback:
return fallback, "hospital_admins_coordinators"
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_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_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:
raise ComplaintServiceError(
f"Cannot assign complaint with status '{complaint.get_status_display()}'. "
"Complaint must be Open, In Progress, or Partially Resolved."
)
if not (assigned_by.is_px_admin() or assigned_by.is_hospital_admin()):
raise ComplaintServiceError("You don't have permission to assign complaints.")
old_assignee = complaint.assigned_to
complaint.assigned_to = target_user
complaint.assigned_at = timezone.now()
complaint.save(update_fields=["assigned_to", "assigned_at"])
roles_display = ", ".join(target_user.get_role_names())
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="assignment",
message=f"Assigned to {target_user.get_full_name()} ({roles_display})",
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(),
},
)
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,
}
@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
complaint.status = new_status
if new_status == ComplaintStatus.RESOLVED or new_status == "resolved":
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))
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 request_explanation(
complaint,
staff_list,
selected_staff_ids,
selected_manager_ids,
request_message,
requested_by,
domain,
request=None,
):
import secrets
if not complaint.is_active_status:
raise ComplaintServiceError(
f"Cannot request explanation for complaint with status '{complaint.get_status_display()}'. "
"Complaint must be Open, In Progress, or Partially Resolved."
)
staff_count = 0
manager_count = 0
skipped_no_email = 0
results = []
notified_managers = set()
for recipient in staff_list:
staff = recipient["staff"]
staff_id = recipient["staff_id"]
if staff_id not in selected_staff_ids:
continue
staff_email = recipient.get("staff_email")
if not staff_email:
skipped_no_email += 1
continue
staff_token = secrets.token_urlsafe(32)
explanation, created = ComplaintExplanation.objects.update_or_create(
complaint=complaint,
staff=staff,
defaults={
"token": staff_token,
"is_used": False,
"requested_by": requested_by,
"request_message": request_message,
"email_sent_at": timezone.now(),
"submitted_via": "email_link",
},
)
staff_link = f"https://{domain}/complaints/{complaint.id}/explain/{staff_token}/"
staff_subject = f"Explanation Request - Complaint #{complaint.reference_number}"
staff_display = recipient.get("staff_name", str(staff))
staff_email_body = f"""Dear {staff_display},
We have received a complaint that requires your explanation.
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:
staff_email_body += f"""
PATIENT INFORMATION:
------------------
Name: {complaint.patient.get_full_name()}
MRN: {complaint.patient.mrn or "N/A"}"""
if request_message:
staff_email_body += f"""
ADDITIONAL MESSAGE:
------------------
{request_message}"""
staff_email_body += f"""
SUBMIT YOUR EXPLANATION:
------------------------
Please submit your explanation about this complaint:
{staff_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=staff_email,
subject=staff_subject,
message=staff_email_body,
related_object=complaint,
metadata={
"notification_type": "explanation_request",
"staff_id": str(staff.id),
"complaint_id": str(complaint.id),
},
)
staff_count += 1
results.append(
{
"recipient_type": "staff",
"recipient": staff_display,
"email": staff_email,
"explanation_id": str(explanation.id),
"sent": True,
}
)
except Exception as e:
logger.error(f"Failed to send explanation request to staff {staff.id}: {e}")
results.append(
{
"recipient_type": "staff",
"recipient": staff_display,
"email": staff_email,
"explanation_id": str(explanation.id),
"sent": False,
"error": str(e),
}
)
manager = recipient.get("manager")
manager_id = recipient.get("manager_id")
if manager and manager_id and manager_id in selected_manager_ids:
if manager.id not in notified_managers:
manager_email = recipient.get("manager_email")
if manager_email:
manager_display = recipient.get("manager_name", str(manager))
manager_subject = f"Staff Explanation Requested - Complaint #{complaint.reference_number}"
manager_email_body = f"""Dear {manager_display},
This is an informational notification that an explanation has been requested from a staff member who reports to you.
STAFF MEMBER:
------------
Name: {staff_display}
Department: {recipient.get("department", "")}
Role in Complaint: {recipient.get("role", "")}
COMPLAINT DETAILS:
----------------
Reference: {complaint.reference_number}
Title: {complaint.title}
Severity: {complaint.get_severity_display()}
The staff member has been sent a link to submit their explanation. You will be notified when they respond.
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=manager_email,
subject=manager_subject,
message=manager_email_body,
related_object=complaint,
metadata={
"notification_type": "explanation_request_manager_notification",
"manager_id": str(manager.id),
"staff_id": str(staff.id),
"complaint_id": str(complaint.id),
},
)
manager_count += 1
notified_managers.add(manager.id)
except Exception as e:
logger.error(f"Failed to send manager notification to {manager.id}: {e}")
metadata = {
"staff_count": staff_count,
"manager_count": manager_count,
"skipped_no_email": skipped_no_email,
"selected_staff_ids": selected_staff_ids,
"selected_manager_ids": selected_manager_ids,
}
if request:
AuditService.log_from_request(
event_type="explanation_requested",
description=f"Explanation requests sent to {staff_count} staff and {manager_count} managers",
request=request,
content_object=complaint,
metadata=metadata,
)
else:
AuditService.log_event(
event_type="explanation_requested",
description=f"Explanation requests sent to {staff_count} staff and {manager_count} managers",
user=requested_by,
content_object=complaint,
metadata=metadata,
)
if staff_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: {recipients_str}",
created_by=requested_by,
metadata={
"staff_count": staff_count,
"manager_count": manager_count,
"results": results,
},
)
complaint.status = ComplaintStatus.CONTACTED
complaint.save(update_fields=["status", "updated_at"])
return {
"staff_count": staff_count,
"manager_count": manager_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,
)