agdar/integrations/messaging_service.py
2025-11-02 14:35:35 +03:00

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