Skip to content

Commit 8c770b2

Browse files
authored
[ENG-11764] Filter, order, skip user by activity, registration date and spam status (#11815)
* sort recipients by activity priority * simplify filter; send campaign email tasks in 3 phases * add global values to defaults and admin campaign metadata fields
1 parent 3e5d7c1 commit 8c770b2

6 files changed

Lines changed: 132 additions & 35 deletions

File tree

admin/notifications/forms.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django import forms
22
from osf.models import NotificationType, NotificationCampaign
3+
from website import settings
34
import json
45

56

@@ -24,12 +25,18 @@ class NotificationCampaignCreateForm(forms.ModelForm):
2425

2526
batch_size = forms.IntegerField(
2627
min_value=1,
27-
initial=1000,
28+
initial=settings.DEFAULT_CAMPAIGN_BATCH_SIZE,
2829
)
2930

3031
max_retries = forms.IntegerField(
3132
min_value=0,
32-
initial=3,
33+
initial=settings.DEFAULT_CAMPAIGN_MAX_RETRIES,
34+
)
35+
36+
activity_threshold = forms.IntegerField(
37+
min_value=0,
38+
initial=settings.DEFAULT_CAMPAIGN_ACTIVITY_THRESHOLD,
39+
help_text='Non-spam users at or above this activity total are sent in the high-activity phase.',
3340
)
3441

3542
class Meta:

admin/notifications/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ def form_valid(self, form):
508508
'execution': {
509509
'batch_size': form.cleaned_data['batch_size'],
510510
'max_retries': form.cleaned_data['max_retries'],
511+
'activity_threshold': form.cleaned_data['activity_threshold'],
511512
},
512513
}
513514

admin/templates/notifications/notification_campaing_create.html

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ <h4>Execution</h4>
152152
class="form-control"
153153
type="number"
154154
name="batch_size"
155-
value="1000"
155+
value="{{ form.batch_size.initial }}"
156156
min="1"
157157
>
158158
</td>
@@ -165,11 +165,27 @@ <h4>Execution</h4>
165165
class="form-control"
166166
type="number"
167167
name="max_retries"
168-
value="3"
168+
value="{{ form.max_retries.initial }}"
169169
min="0"
170170
>
171171
</td>
172172
</tr>
173+
174+
<tr>
175+
<th>Activity Threshold</th>
176+
<td>
177+
<input
178+
class="form-control"
179+
type="number"
180+
name="activity_threshold"
181+
value="{{ form.activity_threshold.initial }}"
182+
min="0"
183+
>
184+
<p class="help-block">
185+
Non-spam users at or above this activity total are sent first
186+
</p>
187+
</td>
188+
</tr>
173189
</table>
174190
</div>
175191
</div>

osf/email/notification_campaign.py

Lines changed: 98 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import logging
2+
23
from osf.models import NotificationType, NotificationTypeEnum, OSFUser, UserActivityCounter, Email
4+
from osf.models.spam import SpamStatus
35
from django.db.models import OuterRef, Subquery, Exists, F, Q, Case, When, CharField
46
from django.db.models.functions import Coalesce
57
from framework.celery_tasks import app as celery_app
6-
from celery import chord
8+
from celery import chord, group, chain
79
from django.utils import timezone
810
from datetime import timedelta
911
from osf.models.notification_campaign import NotificationCampaign, NotificationCampaignRecipient, NotificationCampaignStatus, NotificationCampaignRecipientStatus
1012
from osf.email import send_email_with_send_grid
1113
from framework import sentry
14+
from website import settings
1215

1316
logger = 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

91135
FILTER_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')
98142
def 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)

osf/models/notification_campaign.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class NotificationCampaign(models.Model):
5757
# "execution": {
5858
# "batch_size": <int>,
5959
# "max_retries": <int>,
60+
# "activity_threshold": <int>,
6061
# },
6162
# "template": <str>,
6263
# }

website/settings/defaults.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ def parent_dir(path):
191191
NOTIFICATIONS_CLEANUP_AGE = timedelta(weeks=12) # 3 months to clean up old notifications and email tasks
192192
NOTIFICATIONS_CLEANUP_BATCH_SIZE = 10000 # Batch size for notifications and email tasks cleanup
193193

194+
# Notification campaign execution defaults (overridable per campaign in admin metadata)
195+
DEFAULT_CAMPAIGN_ACTIVITY_THRESHOLD = 3 # Users at/above this activity total are scheduled in the high-activity phase
196+
DEFAULT_CAMPAIGN_BATCH_SIZE = 1000
197+
DEFAULT_CAMPAIGN_MAX_RETRIES = 3
198+
194199
# Configuration for "We miss you at OSF" email (`NotificationTypeEnum.USER_NO_LOGIN`)
195200
# Note: 1) we can gradually increase `MAX_DAILY_NO_LOGIN_EMAILS` to 10000, 100000, etc. or set it to `None` after we
196201
# have verified that users are not spammed by this email after NR release. 2) If we want to clean up database for those

0 commit comments

Comments
 (0)