nexus-5/core/tasks/notifications.py
2026-01-26 11:09:40 -05:00

268 lines
8.7 KiB
Python

"""
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'])