import logging from celery import shared_task logger = logging.getLogger(__name__) def _bind_dispatch_tenant(tenant_id, dispatch): from infrasynth.tenancy.context import tenant_context from infrasynth.tenancy.models import Tenant if tenant_id: tenant = Tenant.objects.filter(pk=tenant_id).first() else: tenant = dispatch.tenant return tenant_context(tenant) @shared_task( name="infrasynth.notifications.deliver_notification", bind=True, max_retries=3, default_retry_delay=60, ) def deliver_notification(self, dispatch_id, tenant_id=None): from .models import NotificationDispatch from .services import NotificationService try: dispatch = NotificationDispatch.all_objects.select_related("template").get(pk=dispatch_id) except NotificationDispatch.DoesNotExist: logger.warning("Notification dispatch %s not found", dispatch_id) return None with _bind_dispatch_tenant(tenant_id, dispatch): service = NotificationService() try: service._execute_send(dispatch) except Exception as exc: # noqa: BLE001 logger.exception("Dispatch %s failed", dispatch_id) raise self.retry(exc=exc, countdown=60) from exc return dispatch_id @shared_task( name="infrasynth.notifications.deliver_notification_with_failover", bind=True, max_retries=3, default_retry_delay=60, ) def deliver_notification_with_failover(self, dispatch_id, channels, tenant_id=None): from .models import NotificationDispatch from .services import NotificationService try: dispatch = NotificationDispatch.all_objects.select_related("template").get(pk=dispatch_id) except NotificationDispatch.DoesNotExist: logger.warning("Notification dispatch %s not found", dispatch_id) return None with _bind_dispatch_tenant(tenant_id, dispatch): service = NotificationService() try: service._execute_failover(dispatch, channels) except Exception as exc: # noqa: BLE001 logger.exception("Dispatch %s failover failed", dispatch_id) raise self.retry(exc=exc, countdown=60) from exc return dispatch_id @shared_task(name="infrasynth.notifications.retry_pending_dispatches") def retry_pending_dispatches(batch_size=None): """Re-delivers dispatches whose backoff window has elapsed (beat-scheduled).""" from django.utils import timezone from infrasynth.shared.settings_utils import get_setting from .models import NotificationDispatch if batch_size is None: batch_size = int(get_setting("INFRASYNTH_NOTIFICATIONS", "RETRY_SCAN_BATCH_SIZE", 100)) due = list( NotificationDispatch.all_objects.filter( status=NotificationDispatch.Status.RETRYING, next_retry_at__lte=timezone.now(), ) .order_by("next_retry_at") .values_list("id", "tenant_id")[:batch_size] ) for dispatch_id, tenant_id in due: deliver_notification.delay(dispatch_id, str(tenant_id) if tenant_id else None) return len(due) @shared_task(name="infrasynth.notifications.purge_old_dispatches") def purge_old_dispatches(): """Deletes finished dispatches older than ``DISPATCH_LOG_RETENTION_DAYS``.""" from datetime import timedelta from django.utils import timezone from infrasynth.shared.settings_utils import get_setting from .models import NotificationDispatch days = int(get_setting("INFRASYNTH_NOTIFICATIONS", "DISPATCH_LOG_RETENTION_DAYS", 90)) if days <= 0: return 0 cutoff = timezone.now() - timedelta(days=days) deleted, _ = NotificationDispatch.all_objects.filter( status__in=[NotificationDispatch.Status.SENT, NotificationDispatch.Status.FAILED], created_at__lt=cutoff, ).delete() return deleted