11import logging
2+
23from osf .models import NotificationType , NotificationTypeEnum , OSFUser , UserActivityCounter , Email
4+ from osf .models .spam import SpamStatus
35from django .db .models import OuterRef , Subquery , Exists , F , Q , Case , When , CharField
46from django .db .models .functions import Coalesce
57from framework .celery_tasks import app as celery_app
6- from celery import chord
8+ from celery import chord , group , chain
79from django .utils import timezone
810from datetime import timedelta
911from osf .models .notification_campaign import NotificationCampaign , NotificationCampaignRecipient , NotificationCampaignStatus , NotificationCampaignRecipientStatus
1012from osf .email import send_email_with_send_grid
1113from framework import sentry
14+ from website import settings
1215
1316logger = logging .getLogger (__name__ )
1417
@@ -48,10 +51,27 @@ def filter_users(filters, campaign_id=None, restart_failed=False):
4851 return qs
4952
5053
51- def get_filtered_batches (filters , batch_size = 1000 , campaign_id = None , restart_failed = False ):
54+ def get_filtered_batches (
55+ filters ,
56+ batch_size = settings .DEFAULT_CAMPAIGN_BATCH_SIZE ,
57+ campaign_id = None ,
58+ restart_failed = False ,
59+ min_activity = None ,
60+ max_activity = None ,
61+ exclude_spam = False ,
62+ ):
5263 qs = filter_users (filters , campaign_id , restart_failed = restart_failed )
64+ if exclude_spam :
65+ qs = qs .exclude (spam_status = SpamStatus .SPAM )
66+
67+ qs = qs .annotate (activity_total = Coalesce (Subquery (counter_subquery ), 0 ))
68+
69+ if min_activity is not None :
70+ qs = qs .filter (activity_total__gte = min_activity )
71+ if max_activity is not None :
72+ qs = qs .filter (activity_total__lt = max_activity )
5373
54- qs = qs .annotate ( activity_total = Coalesce ( Subquery ( counter_subquery ), 0 )). order_by ('-activity_total' , '-date_registered' , '-id' )
74+ qs = qs .order_by ('-activity_total' , '-date_registered' , '-id' )
5575
5676 last_total = None
5777 last_date = None
@@ -68,24 +88,48 @@ def get_filtered_batches(filters, batch_size=1000, campaign_id=None, restart_fai
6888 )
6989
7090 batch = batch_qs [:batch_size ]
71-
72- if not batch :
73- break
74-
7591 rows = list (batch .values_list ('id' , 'activity_total' , 'date_registered' ))
7692
7793 if not rows :
7894 break
7995
8096 batch_ids = [r [0 ] for r in rows ]
97+ last_id , last_total , last_date = rows [- 1 ]
98+
99+ yield batch_ids
100+
81101
82- last_id , last_total , last_date = (
83- rows [- 1 ][0 ],
84- rows [- 1 ][1 ],
85- rows [- 1 ][2 ],
102+ def build_campaign_group (
103+ filters ,
104+ batch_size = settings .DEFAULT_CAMPAIGN_BATCH_SIZE ,
105+ campaign_id = None ,
106+ restart_failed = False ,
107+ min_activity = None ,
108+ max_activity = None ,
109+ exclude_spam = True ,
110+ ** send_kwargs ,
111+ ):
112+ tasks = []
113+ total_recipients = 0
114+ for batch in get_filtered_batches (
115+ filters ,
116+ batch_size = batch_size ,
117+ campaign_id = campaign_id ,
118+ restart_failed = restart_failed ,
119+ min_activity = min_activity ,
120+ max_activity = max_activity ,
121+ exclude_spam = exclude_spam ,
122+ ):
123+ tasks .append (
124+ send_campaign_batch .si (
125+ recipients_ids = batch ,
126+ campaign_id = campaign_id ,
127+ ** send_kwargs ,
128+ )
86129 )
130+ total_recipients += len (batch )
87131
88- yield batch_ids
132+ return group ( tasks ), total_recipients
89133
90134
91135FILTER_PRESETS = {
@@ -96,12 +140,11 @@ def get_filtered_batches(filters, batch_size=1000, campaign_id=None, restart_fai
96140
97141@celery_app .task (name = 'email.process_campaign_retry' )
98142def process_campaign_retry (* args , ** kwargs ):
99-
100143 campaign_id = kwargs .get ('campaign_id' )
101144 campaign = NotificationCampaign .objects .get (id = campaign_id )
102145 failed_recipients = NotificationCampaignRecipient .objects .filter (campaign = campaign , status = NotificationCampaignRecipientStatus .FAILED )
103- max_retries = campaign .metadata .get ('execution' , {}).get ('max_retries' , 3 )
104- batch_size = campaign .metadata .get ('execution' , {}).get ('batch_size' , 1000 )
146+ max_retries = campaign .metadata .get ('execution' , {}).get ('max_retries' , settings . DEFAULT_CAMPAIGN_MAX_RETRIES )
147+ batch_size = campaign .metadata .get ('execution' , {}).get ('batch_size' , settings . DEFAULT_CAMPAIGN_BATCH_SIZE )
105148 failed_recipients_count = failed_recipients .count ()
106149 if not failed_recipients_count :
107150 campaign .status = NotificationCampaignStatus .COMPLETED
@@ -157,26 +200,50 @@ def start_notification_campaign(campaign_id, restart_failed=False):
157200 else :
158201 manual_filters [f'{ item ["field" ]} __{ item ["lookup" ]} ' ] = [value .strip () for value in item ['value' ].split (',' )]
159202 filters = manual_filters
160- tasks = []
161- total_recipients = 0
162- batch_size = campaign .metadata .get ('execution' , {}).get ('batch_size' , 1000 )
163- for batch in get_filtered_batches (filters = filters , batch_size = batch_size , campaign_id = campaign_id , restart_failed = restart_failed ):
164- tasks .append (
165- send_campaign_batch .s (
166- notification_type_name = notification_type_name ,
167- recipients_ids = batch ,
168- context = context ,
169- campaign_id = campaign_id ,
170- )
171- )
172- total_recipients += len (batch )
203+
204+ execution = campaign .metadata .get ('execution' , {})
205+ batch_size = execution .get ('batch_size' , settings .DEFAULT_CAMPAIGN_BATCH_SIZE )
206+ activity_threshold = execution .get ('activity_threshold' , settings .DEFAULT_CAMPAIGN_ACTIVITY_THRESHOLD )
207+ batch_task_kwargs = dict (
208+ batch_size = batch_size ,
209+ campaign_id = campaign_id ,
210+ restart_failed = restart_failed ,
211+ notification_type_name = notification_type_name ,
212+ context = context ,
213+ )
214+
215+ # Phase 1: non-spam users at/above activity threshold
216+ high_activity_tasks , high_activity_count = build_campaign_group (
217+ filters = filters ,
218+ ** batch_task_kwargs ,
219+ min_activity = activity_threshold ,
220+ )
221+
222+ # Phase 2: non-spam users below threshold (includes zero activity)
223+ low_activity_tasks , low_activity_count = build_campaign_group (
224+ filters = filters ,
225+ ** batch_task_kwargs ,
226+ max_activity = activity_threshold ,
227+ )
228+
229+ # Phase 3: confirmed spam (scheduled only after non-spam phases finish)
230+ spam_users_tasks , spam_users_count = build_campaign_group (
231+ filters = {** filters , 'spam_status' : SpamStatus .SPAM },
232+ ** batch_task_kwargs ,
233+ exclude_spam = False ,
234+ )
235+
236+ total_recipients = high_activity_count + low_activity_count + spam_users_count
173237 if not restart_failed :
174238 campaign .recipient_count = total_recipients
175239 campaign .save (update_fields = ['recipient_count' ])
176240
177- chord (tasks )(
178- process_campaign_retry .s (campaign_id = campaign_id )
179- )
241+ chain (
242+ high_activity_tasks ,
243+ low_activity_tasks ,
244+ spam_users_tasks ,
245+ process_campaign_retry .si (campaign_id = campaign_id )
246+ ).apply_async ()
180247
181248
182249@celery_app .task (name = 'email.send_campaign_batch' , ignore_result = False )
0 commit comments