diff --git a/osf/email/notification_campaign.py b/osf/email/notification_campaign.py index 31a041a5642..e4d4aa81406 100644 --- a/osf/email/notification_campaign.py +++ b/osf/email/notification_campaign.py @@ -5,8 +5,10 @@ from framework.celery_tasks import app as celery_app from celery import chord from django.utils import timezone +from datetime import timedelta from osf.models.notification_campaign import NotificationCampaign, NotificationCampaignRecipient, NotificationCampaignStatus, NotificationCampaignRecipientStatus from osf.email import send_email_with_send_grid +from framework import sentry logger = logging.getLogger(__name__) @@ -109,7 +111,9 @@ def process_campaign_retry(*args, **kwargs): return if campaign.retries < max_retries: - logger.info(f'Retrying {failed_recipients_count} failed recipients for campaign {campaign_id}') + message = f'[Notification Campaign] Retrying {failed_recipients_count} failed recipients for campaign {campaign_id}' + logger.info(message) + sentry.log_message(message) filters = {'id__in': failed_recipients.values_list('user_id', flat=True)} tasks = [] for batch in get_filtered_batches(filters=filters, batch_size=batch_size, campaign_id=campaign_id): @@ -189,8 +193,20 @@ def send_campaign_batch(context, recipients_ids, notification_type_name='blank', ).first() # TODO cache if notification_type is None: + if campaign.status != NotificationCampaignStatus.FAILED: + campaign.status = NotificationCampaignStatus.FAILED + campaign.save() return + execution_time_window = campaign.metadata.get('execution', {}).get('time_window', 8) + if campaign.started_at < timezone.now() - timedelta(hours=execution_time_window): + if not campaign.developer_reminder_sent: + message = f'[Notification Campaign] Campaign {campaign_id} exceeded its execution time window ({execution_time_window}h).' + logger.warning(message) + sentry.log_message(message) + campaign.developer_reminder_sent = True + campaign.save() + recipients_qs = OSFUser.objects.filter(id__in=recipients_ids) recipient_records = { 'to_create': [], @@ -243,6 +259,8 @@ def send_campaign_batch(context, recipients_ids, notification_type_name='blank', except Exception as exc: logger.error(exc) # TODO update error + sentry.log_exception(exc) # TODO update error + recipient_record.status = NotificationCampaignRecipientStatus.FAILED recipient_record.error_message = str(exc) recipient_records[operation].append(recipient_record) diff --git a/osf/migrations/0045_notificationcampaign_notificationcampaignrecipient_and_more.py b/osf/migrations/0045_project_enter.py similarity index 90% rename from osf/migrations/0045_notificationcampaign_notificationcampaignrecipient_and_more.py rename to osf/migrations/0045_project_enter.py index 886ff3b29e9..0c42cba25c7 100644 --- a/osf/migrations/0045_notificationcampaign_notificationcampaignrecipient_and_more.py +++ b/osf/migrations/0045_project_enter.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.26 on 2026-07-09 11:38 +# Generated by Django 4.2.26 on 2026-07-17 11:20 from django.conf import settings from django.db import migrations, models @@ -27,6 +27,7 @@ class Migration(migrations.Migration): ('sent_count', models.PositiveIntegerField(default=0)), ('failed_count', models.PositiveIntegerField(default=0)), ('retries', models.PositiveIntegerField(default=0)), + ('developer_reminder_sent', models.BooleanField(default=False)), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), ('notification_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='osf.notificationtype')), ], @@ -36,7 +37,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('updated_at', models.DateTimeField(auto_now=True)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('failed', 'Failed')], db_index=True, default='pending', max_length=20)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('failed', 'Failed'), ('skipped', 'Skipped'), ('postponed', 'Postponed')], db_index=True, default='pending', max_length=20)), ('error_message', models.TextField(blank=True, null=True)), ('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipients', to='osf.notificationcampaign')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), diff --git a/osf/models/notification_campaign.py b/osf/models/notification_campaign.py index 9cce5c3b966..84e635e2368 100644 --- a/osf/models/notification_campaign.py +++ b/osf/models/notification_campaign.py @@ -15,6 +15,8 @@ class NotificationCampaignRecipientStatus(models.TextChoices): PENDING = 'pending', 'Pending' SENT = 'sent', 'Sent' FAILED = 'failed', 'Failed' + SKIPPED = 'skipped', 'Skipped' + POSTPONED = 'postponed', 'Postponed' class NotificationCampaign(models.Model): @@ -64,6 +66,8 @@ class NotificationCampaign(models.Model): failed_count = models.PositiveIntegerField(default=0) retries = models.PositiveIntegerField(default=0) + developer_reminder_sent = models.BooleanField(default=False) + def start(self, restart_failed=False): from osf.email.notification_campaign import start_notification_campaign self.status = NotificationCampaignStatus.RUNNING