""" 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