""" Celery tasks for notification processing and delivery. """ from celery import shared_task from django.utils import timezone from core.models.events import Event, NotificationDelivery from core.models.enums import NotificationChannelChoices, DeliveryStatusChoices from core.services.notifications import NotificationProcessor @shared_task(bind=True, max_retries=3, default_retry_delay=60) def process_event_notifications(self, event_id: str): """ Process an event and generate notifications based on rules. Args: event_id: UUID of the Event to process This task is triggered automatically when an event is published. """ try: event = Event.objects.get(pk=event_id) notifications = NotificationProcessor.process_event(event) return { 'event_id': str(event_id), 'notifications_created': len(notifications), 'notification_ids': [str(n.id) for n in notifications] } except Event.DoesNotExist: # Event was deleted, nothing to do return {'error': f'Event {event_id} not found'} except Exception as exc: # Retry on failure raise self.retry(exc=exc) @shared_task(bind=True, max_retries=5, default_retry_delay=300) def deliver_notification(self, delivery_id: str): """ Deliver a notification via its specified channel. Args: delivery_id: UUID of the NotificationDelivery to process This task is queued automatically when a NotificationDelivery is created. Handles delivery via IN_APP, EMAIL, or SMS channels. """ try: delivery = NotificationDelivery.objects.select_related('notification').get(pk=delivery_id) # Update status to sending delivery.status = DeliveryStatusChoices.SENDING delivery.attempts += 1 delivery.last_attempt_at = timezone.now() delivery.save(update_fields=['status', 'attempts', 'last_attempt_at', 'updated_at']) # Route to appropriate delivery handler if delivery.channel == NotificationChannelChoices.IN_APP: result = _deliver_in_app(delivery) elif delivery.channel == NotificationChannelChoices.EMAIL: result = _deliver_email(delivery) elif delivery.channel == NotificationChannelChoices.SMS: result = _deliver_sms(delivery) else: raise ValueError(f"Unknown delivery channel: {delivery.channel}") # Update delivery status on success delivery.status = DeliveryStatusChoices.SENT delivery.sent_at = timezone.now() delivery.metadata.update(result) delivery.save(update_fields=['status', 'sent_at', 'metadata', 'updated_at']) # Update notification status if all deliveries are sent _update_notification_status(delivery.notification) return { 'delivery_id': str(delivery_id), 'channel': delivery.channel, 'status': 'sent', 'result': result } except NotificationDelivery.DoesNotExist: return {'error': f'NotificationDelivery {delivery_id} not found'} except Exception as exc: # Update delivery with error try: delivery.status = DeliveryStatusChoices.FAILED delivery.error_message = str(exc) delivery.save(update_fields=['status', 'error_message', 'updated_at']) except: pass # Retry with exponential backoff if self.request.retries < self.max_retries: raise self.retry(exc=exc, countdown=300 * (2 ** self.request.retries)) else: # Max retries reached, mark as failed return { 'delivery_id': str(delivery_id), 'status': 'failed', 'error': str(exc), 'retries': self.request.retries } def _deliver_in_app(delivery: NotificationDelivery) -> dict: """ Deliver in-app notification. For in-app, the notification is already in the database, so just mark as delivered. Args: delivery: NotificationDelivery instance Returns: Result dictionary """ # In-app notifications are already stored in DB # Just need to mark as delivered delivery.delivered_at = timezone.now() delivery.status = DeliveryStatusChoices.DELIVERED delivery.save(update_fields=['delivered_at', 'status', 'updated_at']) return { 'channel': 'in_app', 'delivered_at': delivery.delivered_at.isoformat() } def _deliver_email(delivery: NotificationDelivery) -> dict: """ Deliver email notification via the Emailer microservice. Renders the notification as a branded HTML email using Django templates. Args: delivery: NotificationDelivery instance Returns: Result dictionary with email details Raises: ValueError: If recipient has no email address EmailerServiceError: If the emailer service fails """ from django.conf import settings from core.services.email_service import get_emailer_client from core.services.email_renderer import NotificationEmailRenderer notification = delivery.notification recipient = notification.recipient # Get recipient email if hasattr(recipient, 'email') and recipient.email: recipient_email = recipient.email else: raise ValueError(f"Recipient {recipient} has no email address") # Get recipient name recipient_name = 'there' # Default fallback if hasattr(recipient, 'first_name') and recipient.first_name: recipient_name = recipient.first_name if hasattr(recipient, 'last_name') and recipient.last_name: recipient_name = f"{recipient.first_name} {recipient.last_name}" # Render branded HTML email html_body = NotificationEmailRenderer.render_html( notification=notification, recipient_name=recipient_name, recipient_email=recipient_email ) # Send email via emailer microservice (HTML body) emailer = get_emailer_client() result = emailer.send_email( to=[recipient_email], subject=notification.subject, body=html_body, impersonate_user=settings.EMAILER_DEFAULT_SENDER ) return { 'channel': 'email', 'recipient': recipient_email, 'subject': notification.subject, 'email_id': result.get('id'), 'thread_id': result.get('threadId') } def _deliver_sms(delivery: NotificationDelivery) -> dict: """ Deliver SMS notification via Twilio. Args: delivery: NotificationDelivery instance Returns: Result dictionary with SMS details """ from django.conf import settings notification = delivery.notification recipient = notification.recipient # Get recipient phone number if hasattr(recipient, 'phone') and recipient.phone: recipient_phone = recipient.phone else: raise ValueError(f"Recipient {recipient} has no phone number") # Check Twilio configuration if not hasattr(settings, 'TWILIO_ACCOUNT_SID') or not hasattr(settings, 'TWILIO_AUTH_TOKEN'): raise ValueError("Twilio credentials not configured in settings") # Import Twilio client try: from twilio.rest import Client except ImportError: raise ImportError("Twilio package not installed. Run: pip install twilio") # Initialize Twilio client client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) # Send SMS message = client.messages.create( body=f"{notification.subject}\n\n{notification.body}", from_=settings.TWILIO_PHONE_NUMBER, to=recipient_phone ) # Store external message ID delivery.external_id = message.sid delivery.save(update_fields=['external_id', 'updated_at']) return { 'channel': 'sms', 'recipient': recipient_phone, 'message_sid': message.sid, 'status': message.status } def _update_notification_status(notification): """ Update notification status based on delivery statuses. Args: notification: Notification instance """ from core.models.enums import NotificationStatusChoices deliveries = notification.deliveries.all() # If all deliveries are sent or delivered, mark notification as sent if all(d.status in [DeliveryStatusChoices.SENT, DeliveryStatusChoices.DELIVERED] for d in deliveries): notification.status = NotificationStatusChoices.SENT notification.save(update_fields=['status', 'updated_at']) # If any delivery failed after max retries, mark notification as failed elif any(d.status == DeliveryStatusChoices.FAILED for d in deliveries): notification.status = NotificationStatusChoices.FAILED notification.save(update_fields=['status', 'updated_at'])