676 lines
24 KiB
Python
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,
|
|
)
|