HH/apps/complaints/services/complaint_service.py
ismail 7369d08012
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
feat: unified reference numbers + feedback modules QA audit
Reference numbers (unified scheme PREFIX-YYYYMM-HOSP-NNNN, e.g. CMP-202606-HHN-0001):
- new ReferenceSequence model + generate_reference() helper (apps/core)
- Complaint/Inquiry/Observation/Appreciation/Suggestion emit unified refs via save()
- prefix-based auto-routing in public track API (CMP/INQ/OBS trackable; APR/SGT internal-only)
- removed legacy CMP-/INQ- generators in ui_views, integrations, px_sources
- migrations: core.0003_referencesequence, appreciation.0006, feedback.0008, observations.0012
- unit tests (format, sanitization, monthly reset, 40-thread concurrency)

QA audit:
- isolated E2E hospital sandbox mirroring HH-N + 10 role users (create_e2e_isolated_env)
- feedback-modules-audit.spec.ts + audit helper (headed, run-to-completion)
- reports/feedback-modules-qa-report.md

Also bundles accumulated in-progress work across complaints, observations,
organizations, templates, and other modules.
2026-06-14 14:29:23 +03:00

880 lines
34 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, 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 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"],
"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()):
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.")
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,
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()
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 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,
)