From 64dfa3e9b0a815ae75985a3b969f842856ed7136 Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Tue, 18 Aug 2026 14:11:00 +0300 Subject: [PATCH 1/2] Add task to delete old notification campaign recipients --- notifications/tasks.py | 40 ++++++++++++++++++++++++++++++++++-- website/settings/defaults.py | 4 ++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/notifications/tasks.py b/notifications/tasks.py index 9775a44fc7a..26deff1f467 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -1,6 +1,6 @@ import itertools from calendar import monthrange -from datetime import date +from datetime import date, timedelta from django.db import connection from django.utils import timezone from django.core.validators import EmailValidator @@ -11,7 +11,7 @@ from framework.postcommit_tasks.handlers import run_postcommit from osf.models import OSFUser, Notification, NotificationTypeEnum, EmailTask, RegistrationProvider, \ - CollectionProvider, AbstractProvider + CollectionProvider, AbstractProvider, NotificationCampaign, NotificationCampaignRecipient from framework.sentry import log_message from osf.registrations.utils import get_registration_provider_submissions_url from osf.utils.permissions import ADMIN @@ -544,3 +544,39 @@ def delete_batch( logger.info(f'Deleted {deleted} rows from {model_name}') delete_batch.delay(app_label, model_name, filters, order_field, batch_size) + + +@celery_app.task( + bind=True, + name='notifications.tasks.delete_notification_campaign_recipients', +) +def delete_notification_campaign_recipients(self): + """Delete recipients for old notification campaigns.""" + + cutoff = timezone.now() - timedelta(days=90) + + campaigns = NotificationCampaign.objects.filter( + completed_at__lt=cutoff, + ).values_list('id', flat=True) + + for campaign_id in campaigns.iterator(): + total_deleted = 0 + while True: + recipient_ids = list( + NotificationCampaignRecipient.objects + .filter(campaign_id=campaign_id) + .values_list('id', flat=True)[:5000] + ) + + if not recipient_ids: + break + + deleted, _ = NotificationCampaignRecipient.objects.filter( + id__in=recipient_ids, + ).delete() + + total_deleted += deleted + + logger.info(f'Deleted {deleted} recipients for campaign {campaign_id}') + + return diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 7305c08b1bd..de4e3bcb61e 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -712,6 +712,10 @@ class CeleryConfig: 'schedule': crontab(minute=0, hour=7), # Daily 2 a.m 'kwargs': {'dry_run': False}, }, + 'delete_notification_campaign_recipients': { + 'task': 'notifications.tasks.delete_notification_campaign_recipients', + 'schedule': crontab(minute=0, hour=3, day_of_month=1), + }, 'clear_expired_sessions': { 'task': 'osf.management.commands.clear_expired_sessions', 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m From 823365763d31c96305b61b6b29f2ea73501676e4 Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Tue, 18 Aug 2026 17:35:22 +0300 Subject: [PATCH 2/2] Implement notification campaign recipients cleanup with configurable settings and add tests --- .../test_campaign_recipient_cleanup.py | 115 ++++++++++++++++++ notifications/tasks.py | 10 +- website/settings/defaults.py | 2 + website/settings/local-ci.py | 2 + 4 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 api_tests/notifications/test_campaign_recipient_cleanup.py diff --git a/api_tests/notifications/test_campaign_recipient_cleanup.py b/api_tests/notifications/test_campaign_recipient_cleanup.py new file mode 100644 index 00000000000..963540d0ae4 --- /dev/null +++ b/api_tests/notifications/test_campaign_recipient_cleanup.py @@ -0,0 +1,115 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone + +from website import settings +from osf.models import ( + NotificationCampaign, + NotificationCampaignRecipient, + NotificationTypeEnum, +) +from osf_tests.factories import AuthUserFactory +from notifications.tasks import delete_notification_campaign_recipients + + +@pytest.mark.django_db +class TestDeleteNotificationCampaignRecipients: + + @pytest.fixture(autouse=True) + def setup(self): + self.notification_type = NotificationTypeEnum.BLANK.instance + + def test_deletes_recipients_for_old_campaigns(self): + now = timezone.now() + cutoff = now - settings.NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_AGE + + campaign = NotificationCampaign.objects.create( + name='Old campaign', + notification_type=self.notification_type, + completed_at=cutoff - timedelta(seconds=1), + ) + + recipients = NotificationCampaignRecipient.objects.bulk_create([ + NotificationCampaignRecipient( + campaign=campaign, + user_id=AuthUserFactory().id, + ), + NotificationCampaignRecipient( + campaign=campaign, + user_id=AuthUserFactory().id, + ), + ]) + + delete_notification_campaign_recipients() + + assert not NotificationCampaignRecipient.objects.filter( + id__in=[recipient.id for recipient in recipients], + ).exists() + + def test_does_not_delete_recipients_for_recent_campaigns(self): + now = timezone.now() + cutoff = now - settings.NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_AGE + + campaign = NotificationCampaign.objects.create( + name='Recent campaign', + notification_type=self.notification_type, + completed_at=cutoff + timedelta(seconds=1), + ) + + recipient = NotificationCampaignRecipient.objects.create( + campaign=campaign, + user_id=AuthUserFactory().id, + ) + + delete_notification_campaign_recipients() + + assert NotificationCampaignRecipient.objects.filter( + id=recipient.id, + ).exists() + + def test_does_not_delete_recipients_for_incomplete_campaigns(self): + campaign = NotificationCampaign.objects.create( + name='Incomplete campaign', + notification_type=self.notification_type, + completed_at=None, + ) + + recipient = NotificationCampaignRecipient.objects.create( + campaign=campaign, + user_id=AuthUserFactory().id, + ) + + delete_notification_campaign_recipients() + + assert NotificationCampaignRecipient.objects.filter( + id=recipient.id, + ).exists() + + def test_deletes_recipients_in_batches(self): + now = timezone.now() + cutoff = now - settings.NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_AGE + + campaign = NotificationCampaign.objects.create( + name='Large campaign', + notification_type=self.notification_type, + completed_at=cutoff - timedelta(seconds=1), + ) + + NotificationCampaignRecipient.objects.bulk_create([ + NotificationCampaignRecipient( + campaign=campaign, + user_id=AuthUserFactory().id, + ) + for _ in range(10) + ]) + + assert NotificationCampaignRecipient.objects.filter( + campaign=campaign, + ).count() == 10 + + delete_notification_campaign_recipients() + + assert not NotificationCampaignRecipient.objects.filter( + campaign=campaign, + ).exists() diff --git a/notifications/tasks.py b/notifications/tasks.py index 26deff1f467..3f58770ef79 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -1,6 +1,6 @@ import itertools from calendar import monthrange -from datetime import date, timedelta +from datetime import date from django.db import connection from django.utils import timezone from django.core.validators import EmailValidator @@ -553,7 +553,7 @@ def delete_batch( def delete_notification_campaign_recipients(self): """Delete recipients for old notification campaigns.""" - cutoff = timezone.now() - timedelta(days=90) + cutoff = timezone.now() - settings.NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_AGE campaigns = NotificationCampaign.objects.filter( completed_at__lt=cutoff, @@ -565,7 +565,7 @@ def delete_notification_campaign_recipients(self): recipient_ids = list( NotificationCampaignRecipient.objects .filter(campaign_id=campaign_id) - .values_list('id', flat=True)[:5000] + .values_list('id', flat=True)[:settings.NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_BATCH_SIZE] ) if not recipient_ids: @@ -577,6 +577,4 @@ def delete_notification_campaign_recipients(self): total_deleted += deleted - logger.info(f'Deleted {deleted} recipients for campaign {campaign_id}') - - return + logger.info(f'Deleted {total_deleted} recipients for campaign {campaign_id}') diff --git a/website/settings/defaults.py b/website/settings/defaults.py index de4e3bcb61e..4030fe5940f 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -190,6 +190,8 @@ def parent_dir(path): NO_LOGIN_OSF4M_WAIT_TIME = timedelta(weeks=52) # 1 year for "We miss you at OSF" email to users created from OSF4M NOTIFICATIONS_CLEANUP_AGE = timedelta(weeks=12) # 3 months to clean up old notifications and email tasks NOTIFICATIONS_CLEANUP_BATCH_SIZE = 10000 # Batch size for notifications and email tasks cleanup +NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_AGE = timedelta(weeks=12) # 3 months to clean up old notification campaign recipients +NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_BATCH_SIZE = 5000 # Batch size for notification campaign recipients cleanup # Notification campaign execution defaults (overridable per campaign in admin metadata) DEFAULT_CAMPAIGN_ACTIVITY_THRESHOLD = 3 # Users at/above this activity total are scheduled in the high-activity phase diff --git a/website/settings/local-ci.py b/website/settings/local-ci.py index eec0e7070e2..1c3636405fb 100644 --- a/website/settings/local-ci.py +++ b/website/settings/local-ci.py @@ -67,6 +67,8 @@ class CeleryConfig(defaults.CeleryConfig): NO_ADDON_WAIT_TIME = timedelta(weeks=8) NO_LOGIN_WAIT_TIME = timedelta(weeks=4) NO_LOGIN_OSF4M_WAIT_TIME = timedelta(weeks=6) +NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_AGE = timedelta(weeks=12) # 3 months to clean up old notification campaign recipients +NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_BATCH_SIZE = 10 # Batch size for notification campaign recipients cleanup # Configuration for "We miss you at OSF" email (`NotificationTypeEnum.USER_NO_LOGIN`) MAX_DAILY_NO_LOGIN_EMAILS = None