All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
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.
318 lines
14 KiB
Python
318 lines
14 KiB
Python
"""
|
|
Complaint signals - Automatic SMS notifications on status changes
|
|
|
|
This module handles automatic SMS notifications to complainants when:
|
|
1. Complaint is created (confirmation)
|
|
2. Complaint status changes to resolved or closed
|
|
3. Auto-sync department from staff when staff is assigned
|
|
"""
|
|
import logging
|
|
|
|
from django.db.models.signals import pre_save, post_save
|
|
from django.dispatch import receiver
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
|
|
from .models import Complaint, ComplaintUpdate, ComplaintInvolvedDepartment
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@receiver(pre_save, sender=Complaint)
|
|
def sync_department_from_staff(sender, instance, **kwargs):
|
|
"""
|
|
Automatically set complaint.department from staff.department when staff is assigned.
|
|
|
|
This ensures the department is always in sync with the assigned staff member,
|
|
regardless of how the complaint is saved (API, admin, forms, etc.).
|
|
"""
|
|
if instance.staff:
|
|
# If staff is assigned, set department from staff's department
|
|
staff_department = instance.staff.department
|
|
if staff_department and instance.department_id != staff_department.id:
|
|
instance.department = staff_department
|
|
logger.info(
|
|
f"Complaint #{instance.id}: Auto-synced department to '{staff_department.name}' "
|
|
f"from staff '{instance.staff.name}'"
|
|
)
|
|
elif instance.pk:
|
|
# If staff is being removed (set to None), check if we should clear department
|
|
# Only clear if the department was originally from a staff member
|
|
# We keep the department if it was manually set
|
|
pass
|
|
|
|
|
|
@receiver(post_save, sender=Complaint)
|
|
def send_complaint_creation_sms(sender, instance, created, **kwargs):
|
|
"""
|
|
Send SMS notification when complaint is created.
|
|
|
|
Only sends for public complaints (those with contact_phone).
|
|
"""
|
|
if not created:
|
|
return
|
|
|
|
# Only send SMS if phone number is provided
|
|
if not instance.contact_phone:
|
|
logger.info(f"Complaint #{instance.id} created but no phone number provided. Skipping SMS.")
|
|
return
|
|
|
|
# Send SMS notification
|
|
try:
|
|
from apps.notifications.services import NotificationService
|
|
|
|
# Get tracking URL
|
|
tracking_url = instance.get_tracking_url()
|
|
|
|
# Bilingual SMS messages
|
|
messages = {
|
|
'en': f"PX360: Your complaint #{instance.reference_number} has been received. Track: {tracking_url}",
|
|
'ar': f"PX360: تم استلام شكوتك #{instance.reference_number}. تتبع الشكوى: {tracking_url}"
|
|
}
|
|
|
|
# Default to English (can be enhanced to detect language)
|
|
sms_message = messages['en']
|
|
|
|
# Send SMS
|
|
notification_log = NotificationService.send_sms(
|
|
phone=instance.contact_phone,
|
|
message=sms_message,
|
|
related_object=instance,
|
|
metadata={
|
|
'notification_type': 'complaint_created',
|
|
'reference_number': instance.reference_number,
|
|
'tracking_url': tracking_url,
|
|
'language': 'en' # Default to English
|
|
}
|
|
)
|
|
|
|
logger.info(f"Creation SMS sent to {instance.contact_phone} for complaint #{instance.id}")
|
|
|
|
# Create complaint update to track SMS
|
|
ComplaintUpdate.objects.create(
|
|
complaint=instance,
|
|
update_type='communication',
|
|
message=f"SMS notification sent to complainant: Your complaint has been received",
|
|
metadata={
|
|
'notification_type': 'complaint_created',
|
|
'notification_log_id': str(notification_log.id) if notification_log else None
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
# Log error but don't fail the complaint save
|
|
logger.error(f"Failed to send creation SMS for complaint #{instance.id}: {str(e)}")
|
|
|
|
|
|
@receiver(post_save, sender=Complaint)
|
|
def send_complaint_status_change_sms(sender, instance, created, **kwargs):
|
|
"""
|
|
Send SMS notification when complaint status changes to resolved or closed.
|
|
|
|
Uses update_fields to detect actual status changes (not just re-saves).
|
|
"""
|
|
# Skip on creation (handled by creation signal)
|
|
if created:
|
|
return
|
|
|
|
# Check if this is a status change to resolved or closed
|
|
# Use update_fields to detect actual status changes
|
|
if not hasattr(instance, '_status_was'):
|
|
return
|
|
|
|
old_status = instance._status_was
|
|
new_status = instance.status
|
|
|
|
# Only send SMS for resolved or closed status changes
|
|
if new_status not in ['resolved', 'closed']:
|
|
return
|
|
|
|
# Only send if status actually changed
|
|
if old_status == new_status:
|
|
return
|
|
|
|
# Only send if phone or email is provided
|
|
if not instance.contact_phone and not instance.contact_email:
|
|
logger.info(f"Complaint #{instance.id} status changed to {new_status} but no contact info. Skipping notification.")
|
|
return
|
|
|
|
# Send SMS + email notification
|
|
try:
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
from apps.core.utils import build_public_track_url
|
|
|
|
track_url = build_public_track_url("complaint", instance.reference_number)
|
|
|
|
status_label = "resolved" if new_status == "resolved" else "closed"
|
|
sms_message = f"PX360: Your complaint #{instance.reference_number} has been {status_label}. View response: {track_url}"
|
|
|
|
if instance.contact_phone:
|
|
notification_log = NotificationService.send_sms(
|
|
phone=instance.contact_phone,
|
|
message=sms_message,
|
|
related_object=instance,
|
|
metadata={
|
|
'notification_type': 'complaint_status_change',
|
|
'reference_number': instance.reference_number,
|
|
'old_status': old_status,
|
|
'new_status': new_status,
|
|
'language': 'en'
|
|
}
|
|
)
|
|
|
|
logger.info(f"Status change SMS sent to {instance.contact_phone} for complaint #{instance.id}: {old_status} -> {new_status}")
|
|
|
|
if instance.contact_email:
|
|
email_subject = f"PX360: Your complaint #{instance.reference_number} has been {status_label}"
|
|
email_body = (
|
|
f"Dear Valued Patient,\n\n"
|
|
f"Your complaint #{instance.reference_number} has been {status_label}.\n\n"
|
|
f"To view the full response, please visit:\n{track_url}\n\n"
|
|
f"Thank you for your feedback.\n\n"
|
|
f"Reference: {instance.reference_number}\n"
|
|
f"This is an automated message from PX 360."
|
|
)
|
|
NotificationService.send_email(
|
|
email=instance.contact_email,
|
|
subject=email_subject,
|
|
message=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;">Complaint Update: {status_label.title()}</h2>
|
|
<p style="margin: 0 0 12px 0;">Dear Valued Patient,</p>
|
|
<p style="margin: 0 0 12px 0;">Your complaint <strong>#{instance.reference_number}</strong> has been <strong>{status_label}</strong>.</p>
|
|
<p style="margin: 0 0 12px 0;">To view the full response, please click the link below:</p>
|
|
<div style="text-align: center; margin: 20px 0;">
|
|
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Response</a>
|
|
</div>
|
|
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">Reference: {instance.reference_number}</p>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=instance,
|
|
metadata={
|
|
'notification_type': 'complaint_status_change_email',
|
|
'reference_number': instance.reference_number,
|
|
}
|
|
)
|
|
logger.info(f"Status change email sent to {instance.contact_email} for complaint #{instance.id}")
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=instance,
|
|
update_type='communication',
|
|
message=f"Notification sent to complainant: Status changed to {new_status} (SMS: {bool(instance.contact_phone)}, Email: {bool(instance.contact_email)})",
|
|
metadata={
|
|
'notification_type': 'complaint_status_change',
|
|
'old_status': old_status,
|
|
'new_status': new_status,
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
# Log error but don't fail the complaint save
|
|
logger.error(f"Failed to send status change SMS for complaint #{instance.id}: {str(e)}")
|
|
|
|
|
|
# Hook into ComplaintUpdate to track SMS sent manually via API
|
|
@receiver(post_save, sender=ComplaintUpdate)
|
|
def track_manual_sms(sender, instance, created, **kwargs):
|
|
"""
|
|
Track manually sent SMS notifications.
|
|
|
|
This ensures that SMS sent via API endpoints (like send_resolution_notification)
|
|
are also properly tracked.
|
|
"""
|
|
if not created:
|
|
return
|
|
|
|
# Check if this update was for a communication/notification
|
|
if instance.update_type == 'communication':
|
|
# Log tracking info
|
|
logger.info(
|
|
f"Manual communication update created for complaint #{instance.complaint.id}: "
|
|
f"{instance.message[:50]}..."
|
|
)
|
|
|
|
|
|
@receiver(post_save, sender=ComplaintInvolvedDepartment)
|
|
def notify_champion_on_department_assignment(sender, instance, created, **kwargs):
|
|
"""
|
|
Send email notification to department champion when a complaint is assigned to their department.
|
|
"""
|
|
if not created:
|
|
return
|
|
|
|
# Only notify when this department is actually being sent to (not just added to involvement list)
|
|
if not instance.sent:
|
|
return
|
|
|
|
# Only notify if the department has a respondent (champion) with email
|
|
if not instance.department.champion or not instance.department.champion.user or not instance.department.champion.user.email:
|
|
logger.info(
|
|
f"ComplaintInvolvedDepartment #{instance.id}: No respondent email configured for department "
|
|
f"'{instance.department.name}'. Skipping notification."
|
|
)
|
|
return
|
|
|
|
try:
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
from django.contrib.sites.models import Site
|
|
|
|
champion = instance.department.champion.user
|
|
complaint = instance.complaint
|
|
department = instance.department
|
|
|
|
# Build response URL
|
|
current_site = Site.objects.get_current()
|
|
domain = current_site.domain if current_site else "px360.tenhal.sa"
|
|
department_url = f"https://{domain}/organizations/departments/{department.pk}/"
|
|
|
|
# Send email
|
|
NotificationService.send_email(
|
|
recipient=champion.email,
|
|
subject=f"New Complaint Assigned - {complaint.reference_number}",
|
|
message=f"""A new complaint has been assigned to your department ({department.name}).
|
|
|
|
Complaint Reference: {complaint.reference_number}
|
|
Title: {complaint.title or 'No title'}
|
|
Patient: {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}
|
|
|
|
Please review and respond through your department page:
|
|
{department_url}
|
|
|
|
Best regards,
|
|
PX360 Team""",
|
|
html_message=f"""
|
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
|
{get_email_header_html()}
|
|
<div style="padding: 30px;">
|
|
<h2 style="color: #005696; margin-top: 0;">New Complaint Assigned</h2>
|
|
<p>A new complaint has been assigned to your department <strong>{department.name}</strong>.</p>
|
|
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 15px 0;">
|
|
<p><strong>Reference:</strong> {complaint.reference_number}</p>
|
|
<p><strong>Title:</strong> {complaint.title or 'No title'}</p>
|
|
<p><strong>Patient:</strong> {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}</p>
|
|
</div>
|
|
<p>Please review and respond through your department page:</p>
|
|
<a href="{department_url}" style="display: inline-block; padding: 12px 24px; background: #005696; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0;">View Department Page</a>
|
|
<p style="color: #94a3b8; font-size: 12px; margin-top: 20px;">Best regards,<br>PX360 Team</p>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=complaint,
|
|
metadata={
|
|
'notification_type': 'complaint_department_assigned',
|
|
'complaint_id': str(complaint.id),
|
|
'department_id': str(department.id),
|
|
'champion_email': champion.email,
|
|
}
|
|
)
|
|
|
|
logger.info(
|
|
f"Notification sent to champion {champion.email} for complaint "
|
|
f"#{complaint.reference_number} assigned to department {department.name}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send champion notification for ComplaintInvolvedDepartment #{instance.id}: {str(e)}") |