268 lines
8.7 KiB
Python
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'])
|