568 lines
19 KiB
Python
568 lines
19 KiB
Python
"""
|
|
Messaging Service for Tenhal Healthcare Platform.
|
|
|
|
This service provides a high-level interface for sending SMS and WhatsApp messages,
|
|
integrating with the notification system and handling delivery tracking.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Dict, Optional, List
|
|
from django.conf import settings
|
|
from django.utils import timezone
|
|
from django.db import transaction
|
|
|
|
from .sms_providers import ProviderFactory, MessageStatus
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MessagingService:
|
|
"""
|
|
High-level service for sending SMS and WhatsApp messages.
|
|
|
|
Features:
|
|
- Template rendering with variable substitution
|
|
- Multi-channel delivery (SMS, WhatsApp, Email)
|
|
- Delivery tracking and status updates
|
|
- Retry logic for failed messages
|
|
- Patient preference checking
|
|
- Audit logging
|
|
|
|
Usage:
|
|
service = MessagingService()
|
|
|
|
# Send using template
|
|
result = service.send_from_template(
|
|
template_code='appointment_reminder',
|
|
recipient_phone='+966501234567',
|
|
channel='SMS',
|
|
context={'patient_name': 'Ahmed', 'date': '2025-10-15'}
|
|
)
|
|
|
|
# Send direct message
|
|
result = service.send_message(
|
|
to='+966501234567',
|
|
message='Your appointment is tomorrow',
|
|
channel='SMS'
|
|
)
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize messaging service with providers."""
|
|
self.sms_provider = ProviderFactory.create_sms_provider()
|
|
self.whatsapp_provider = ProviderFactory.create_whatsapp_provider()
|
|
|
|
def send_from_template(
|
|
self,
|
|
template_code: str,
|
|
recipient_phone: str,
|
|
channel: str,
|
|
context: Dict,
|
|
patient_id: Optional[str] = None,
|
|
tenant_id: Optional[str] = None,
|
|
language: str = 'en'
|
|
) -> Dict:
|
|
"""
|
|
Send a message using a template.
|
|
|
|
Args:
|
|
template_code: Template code (e.g., 'appointment_reminder')
|
|
recipient_phone: Recipient phone number
|
|
channel: 'SMS', 'WHATSAPP', or 'EMAIL'
|
|
context: Variables to substitute in template
|
|
patient_id: Patient UUID (optional, for preference checking)
|
|
tenant_id: Tenant UUID (required for multi-tenancy)
|
|
language: 'en' or 'ar'
|
|
|
|
Returns:
|
|
dict: {
|
|
'success': bool,
|
|
'message_id': str,
|
|
'status': str,
|
|
'error': str (if failed)
|
|
}
|
|
"""
|
|
from notifications.models import MessageTemplate, Message
|
|
|
|
try:
|
|
# Get template
|
|
template = MessageTemplate.objects.get(
|
|
code=template_code,
|
|
channel=channel,
|
|
is_active=True
|
|
)
|
|
|
|
# Check patient preferences if patient_id provided
|
|
if patient_id:
|
|
can_send = self._check_patient_preferences(
|
|
patient_id,
|
|
channel,
|
|
template_code
|
|
)
|
|
if not can_send:
|
|
logger.info(f"Message blocked by patient preferences: {patient_id}")
|
|
return {
|
|
'success': False,
|
|
'error': 'Patient has disabled this notification type'
|
|
}
|
|
|
|
# Render template
|
|
message_body = template.render(language=language, **context)
|
|
|
|
# Create message record
|
|
message = Message.objects.create(
|
|
template=template,
|
|
channel=channel,
|
|
recipient=recipient_phone,
|
|
subject=template.subject if channel == 'EMAIL' else '',
|
|
body=message_body,
|
|
variables_used=context,
|
|
status=Message.Status.QUEUED,
|
|
tenant_id=tenant_id
|
|
)
|
|
|
|
# Send message
|
|
result = self.send_message(
|
|
to=recipient_phone,
|
|
message=message_body,
|
|
channel=channel,
|
|
message_record=message
|
|
)
|
|
|
|
return result
|
|
|
|
except MessageTemplate.DoesNotExist:
|
|
logger.error(f"Template not found: {template_code}")
|
|
return {
|
|
'success': False,
|
|
'error': f'Template not found: {template_code}'
|
|
}
|
|
except Exception as exc:
|
|
logger.error(f"Failed to send from template: {exc}")
|
|
return {
|
|
'success': False,
|
|
'error': str(exc)
|
|
}
|
|
|
|
def send_message(
|
|
self,
|
|
to: str,
|
|
message: str,
|
|
channel: str,
|
|
message_record: Optional['Message'] = None,
|
|
tenant_id: Optional[str] = None
|
|
) -> Dict:
|
|
"""
|
|
Send a direct message without using a template.
|
|
|
|
Args:
|
|
to: Recipient phone number or email
|
|
message: Message body
|
|
channel: 'SMS', 'WHATSAPP', or 'EMAIL'
|
|
message_record: Existing Message model instance (optional)
|
|
tenant_id: Tenant UUID (required if message_record not provided)
|
|
|
|
Returns:
|
|
dict: Send result with success status and message_id
|
|
"""
|
|
from notifications.models import Message, MessageLog
|
|
|
|
try:
|
|
# Create message record if not provided
|
|
if not message_record:
|
|
message_record = Message.objects.create(
|
|
channel=channel,
|
|
recipient=to,
|
|
body=message,
|
|
status=Message.Status.QUEUED,
|
|
tenant_id=tenant_id
|
|
)
|
|
|
|
# Log sending attempt
|
|
MessageLog.objects.create(
|
|
message=message_record,
|
|
event_type=MessageLog.EventType.SENDING,
|
|
details={'attempt': message_record.retry_count + 1}
|
|
)
|
|
|
|
# Send via appropriate provider
|
|
if channel == 'SMS':
|
|
provider_result = self.sms_provider.send_sms(to, message)
|
|
elif channel == 'WHATSAPP':
|
|
provider_result = self.whatsapp_provider.send_message(to, message)
|
|
elif channel == 'EMAIL':
|
|
# Email handled separately (Django's email backend)
|
|
provider_result = self._send_email(to, message)
|
|
else:
|
|
raise ValueError(f"Unsupported channel: {channel}")
|
|
|
|
# Update message record with result
|
|
with transaction.atomic():
|
|
if provider_result.success:
|
|
message_record.status = Message.Status.SENT
|
|
message_record.sent_at = timezone.now()
|
|
message_record.provider_message_id = provider_result.message_id
|
|
message_record.provider_response = provider_result.metadata
|
|
|
|
MessageLog.objects.create(
|
|
message=message_record,
|
|
event_type=MessageLog.EventType.SENT,
|
|
details={
|
|
'provider_message_id': provider_result.message_id,
|
|
'cost': provider_result.cost,
|
|
}
|
|
)
|
|
else:
|
|
message_record.status = Message.Status.FAILED
|
|
message_record.error_message = provider_result.error_message
|
|
message_record.retry_count += 1
|
|
|
|
MessageLog.objects.create(
|
|
message=message_record,
|
|
event_type=MessageLog.EventType.FAILED,
|
|
details={
|
|
'error_code': provider_result.error_code,
|
|
'error_message': provider_result.error_message,
|
|
}
|
|
)
|
|
|
|
message_record.save()
|
|
|
|
return {
|
|
'success': provider_result.success,
|
|
'message_id': str(message_record.id),
|
|
'provider_message_id': provider_result.message_id,
|
|
'status': provider_result.status.value,
|
|
'error': provider_result.error_message if not provider_result.success else None
|
|
}
|
|
|
|
except Exception as exc:
|
|
logger.error(f"Failed to send message: {exc}")
|
|
|
|
if message_record:
|
|
message_record.status = Message.Status.FAILED
|
|
message_record.error_message = str(exc)
|
|
message_record.save()
|
|
|
|
return {
|
|
'success': False,
|
|
'error': str(exc)
|
|
}
|
|
|
|
def update_message_status(self, message_id: str) -> Dict:
|
|
"""
|
|
Update message status by checking with provider.
|
|
|
|
Args:
|
|
message_id: Message UUID from database
|
|
|
|
Returns:
|
|
dict: Updated status information
|
|
"""
|
|
from notifications.models import Message, MessageLog
|
|
|
|
try:
|
|
message = Message.objects.get(id=message_id)
|
|
|
|
if not message.provider_message_id:
|
|
return {
|
|
'success': False,
|
|
'error': 'No provider message ID available'
|
|
}
|
|
|
|
# Get status from provider
|
|
if message.channel == 'SMS':
|
|
provider_result = self.sms_provider.get_message_status(
|
|
message.provider_message_id
|
|
)
|
|
elif message.channel == 'WHATSAPP':
|
|
provider_result = self.whatsapp_provider.get_message_status(
|
|
message.provider_message_id
|
|
)
|
|
else:
|
|
return {
|
|
'success': False,
|
|
'error': f'Status check not supported for {message.channel}'
|
|
}
|
|
|
|
# Update message status
|
|
old_status = message.status
|
|
new_status = self._map_provider_status(provider_result.status)
|
|
|
|
if old_status != new_status:
|
|
message.status = new_status
|
|
|
|
if new_status == Message.Status.DELIVERED:
|
|
message.delivered_at = timezone.now()
|
|
event_type = MessageLog.EventType.DELIVERED
|
|
elif new_status == Message.Status.FAILED:
|
|
event_type = MessageLog.EventType.FAILED
|
|
elif new_status == Message.Status.READ:
|
|
event_type = MessageLog.EventType.READ
|
|
else:
|
|
event_type = MessageLog.EventType.SENT
|
|
|
|
message.save()
|
|
|
|
MessageLog.objects.create(
|
|
message=message,
|
|
event_type=event_type,
|
|
details=provider_result.metadata
|
|
)
|
|
|
|
return {
|
|
'success': True,
|
|
'status': new_status,
|
|
'previous_status': old_status,
|
|
'updated': old_status != new_status
|
|
}
|
|
|
|
except Message.DoesNotExist:
|
|
return {
|
|
'success': False,
|
|
'error': 'Message not found'
|
|
}
|
|
except Exception as exc:
|
|
logger.error(f"Failed to update message status: {exc}")
|
|
return {
|
|
'success': False,
|
|
'error': str(exc)
|
|
}
|
|
|
|
def retry_failed_message(self, message_id: str) -> Dict:
|
|
"""
|
|
Retry sending a failed message.
|
|
|
|
Args:
|
|
message_id: Message UUID from database
|
|
|
|
Returns:
|
|
dict: Retry result
|
|
"""
|
|
from notifications.models import Message, MessageLog
|
|
|
|
try:
|
|
message = Message.objects.get(id=message_id)
|
|
|
|
if not message.can_retry:
|
|
return {
|
|
'success': False,
|
|
'error': 'Message cannot be retried (max retries reached or not failed)'
|
|
}
|
|
|
|
# Log retry attempt
|
|
MessageLog.objects.create(
|
|
message=message,
|
|
event_type=MessageLog.EventType.RETRY,
|
|
details={'retry_count': message.retry_count + 1}
|
|
)
|
|
|
|
# Reset status and resend
|
|
message.status = Message.Status.QUEUED
|
|
message.error_message = ''
|
|
message.save()
|
|
|
|
return self.send_message(
|
|
to=message.recipient,
|
|
message=message.body,
|
|
channel=message.channel,
|
|
message_record=message
|
|
)
|
|
|
|
except Message.DoesNotExist:
|
|
return {
|
|
'success': False,
|
|
'error': 'Message not found'
|
|
}
|
|
except Exception as exc:
|
|
logger.error(f"Failed to retry message: {exc}")
|
|
return {
|
|
'success': False,
|
|
'error': str(exc)
|
|
}
|
|
|
|
def send_bulk_messages(
|
|
self,
|
|
recipients: List[str],
|
|
message: str,
|
|
channel: str,
|
|
tenant_id: str
|
|
) -> Dict:
|
|
"""
|
|
Send the same message to multiple recipients.
|
|
|
|
Args:
|
|
recipients: List of phone numbers or emails
|
|
message: Message body
|
|
channel: 'SMS', 'WHATSAPP', or 'EMAIL'
|
|
tenant_id: Tenant UUID
|
|
|
|
Returns:
|
|
dict: Bulk send statistics
|
|
"""
|
|
results = {
|
|
'total': len(recipients),
|
|
'sent': 0,
|
|
'failed': 0,
|
|
'errors': []
|
|
}
|
|
|
|
for recipient in recipients:
|
|
result = self.send_message(
|
|
to=recipient,
|
|
message=message,
|
|
channel=channel,
|
|
tenant_id=tenant_id
|
|
)
|
|
|
|
if result['success']:
|
|
results['sent'] += 1
|
|
else:
|
|
results['failed'] += 1
|
|
results['errors'].append({
|
|
'recipient': recipient,
|
|
'error': result.get('error')
|
|
})
|
|
|
|
logger.info(f"Bulk send completed: {results['sent']}/{results['total']} sent")
|
|
|
|
return results
|
|
|
|
def _check_patient_preferences(
|
|
self,
|
|
patient_id: str,
|
|
channel: str,
|
|
notification_type: str
|
|
) -> bool:
|
|
"""
|
|
Check if patient allows this type of notification on this channel.
|
|
|
|
Args:
|
|
patient_id: Patient UUID
|
|
channel: 'SMS', 'WHATSAPP', or 'EMAIL'
|
|
notification_type: Type of notification (e.g., 'appointment_reminder')
|
|
|
|
Returns:
|
|
bool: True if notification can be sent
|
|
"""
|
|
from notifications.models import NotificationPreference
|
|
|
|
try:
|
|
prefs = NotificationPreference.objects.get(patient_id=patient_id)
|
|
return prefs.can_send(channel, notification_type)
|
|
except NotificationPreference.DoesNotExist:
|
|
# If no preferences set, allow all notifications
|
|
return True
|
|
|
|
def _send_email(self, to: str, message: str, subject: str = '') -> 'MessageResult':
|
|
"""
|
|
Send email using Django's email backend.
|
|
|
|
Args:
|
|
to: Email address
|
|
message: Email body
|
|
subject: Email subject
|
|
|
|
Returns:
|
|
MessageResult
|
|
"""
|
|
from django.core.mail import send_mail
|
|
from .sms_providers import MessageResult, MessageStatus
|
|
|
|
try:
|
|
send_mail(
|
|
subject=subject or 'Notification from Tenhal Healthcare',
|
|
message=message,
|
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
recipient_list=[to],
|
|
fail_silently=False,
|
|
)
|
|
|
|
return MessageResult(
|
|
success=True,
|
|
message_id=f"email_{timezone.now().timestamp()}",
|
|
status=MessageStatus.SENT,
|
|
metadata={'provider': 'django_email'}
|
|
)
|
|
|
|
except Exception as exc:
|
|
logger.error(f"Email send failed: {exc}")
|
|
return MessageResult(
|
|
success=False,
|
|
error_code='EMAIL_ERROR',
|
|
error_message=str(exc),
|
|
metadata={'provider': 'django_email'}
|
|
)
|
|
|
|
def _map_provider_status(self, provider_status: MessageStatus) -> str:
|
|
"""
|
|
Map provider status to Message model status.
|
|
|
|
Args:
|
|
provider_status: MessageStatus enum
|
|
|
|
Returns:
|
|
str: Message.Status choice
|
|
"""
|
|
from notifications.models import Message
|
|
|
|
status_map = {
|
|
MessageStatus.QUEUED: Message.Status.QUEUED,
|
|
MessageStatus.SENT: Message.Status.SENT,
|
|
MessageStatus.DELIVERED: Message.Status.DELIVERED,
|
|
MessageStatus.FAILED: Message.Status.FAILED,
|
|
MessageStatus.UNDELIVERED: Message.Status.FAILED,
|
|
MessageStatus.READ: Message.Status.READ,
|
|
}
|
|
|
|
return status_map.get(provider_status, Message.Status.QUEUED)
|
|
|
|
def get_message_statistics(self, tenant_id: str, days: int = 7) -> Dict:
|
|
"""
|
|
Get messaging statistics for a tenant.
|
|
|
|
Args:
|
|
tenant_id: Tenant UUID
|
|
days: Number of days to look back
|
|
|
|
Returns:
|
|
dict: Statistics by channel and status
|
|
"""
|
|
from notifications.models import Message
|
|
from django.db.models import Count, Q
|
|
from datetime import timedelta
|
|
|
|
since = timezone.now() - timedelta(days=days)
|
|
|
|
messages = Message.objects.filter(
|
|
tenant_id=tenant_id,
|
|
created_at__gte=since
|
|
)
|
|
|
|
stats = {
|
|
'total': messages.count(),
|
|
'by_channel': {},
|
|
'by_status': {},
|
|
'success_rate': 0,
|
|
}
|
|
|
|
# Count by channel
|
|
for channel in ['SMS', 'WHATSAPP', 'EMAIL']:
|
|
count = messages.filter(channel=channel).count()
|
|
stats['by_channel'][channel] = count
|
|
|
|
# Count by status
|
|
for status in Message.Status:
|
|
count = messages.filter(status=status.value).count()
|
|
stats['by_status'][status.value] = count
|
|
|
|
# Calculate success rate
|
|
successful = messages.filter(
|
|
Q(status=Message.Status.DELIVERED) | Q(status=Message.Status.READ)
|
|
).count()
|
|
|
|
if stats['total'] > 0:
|
|
stats['success_rate'] = round((successful / stats['total']) * 100, 2)
|
|
|
|
return stats
|