Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions api_tests/notifications/test_campaign_recipient_cleanup.py
Original file line number Diff line number Diff line change
@@ -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()
36 changes: 35 additions & 1 deletion notifications/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -544,3 +544,37 @@ 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() - settings.NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_AGE

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)[:settings.NOTIFICATION_CAMPAIGN_RECIPIENTS_CLEANUP_BATCH_SIZE]
)

if not recipient_ids:
break

deleted, _ = NotificationCampaignRecipient.objects.filter(
id__in=recipient_ids,
).delete()

total_deleted += deleted

logger.info(f'Deleted {total_deleted} recipients for campaign {campaign_id}')
6 changes: 6 additions & 0 deletions website/settings/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -712,6 +714,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
Expand Down
2 changes: 2 additions & 0 deletions website/settings/local-ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading