Skip to content

Commit aae133d

Browse files
[ENG-11605][ENG-11695][ENG-11696][ENG-11697][ENG-11605][ENG-11698] ENTER - Part 1 (#11805)
* Implement notification campaign models and email handling - Add NotificationCampaign and NotificationCampaignRecipient models. - Introduce notification campaign status management. - Update OSFUser model to track received notification campaigns. - Enhance email templates for notification campaigns. - Create migration for new models and relationships. * Remove notification settings table from blank email template * Code clean-up and fixes --------- Co-authored-by: Longze Chen <cslzchen@gmail.com>
1 parent 3ea9764 commit aae133d

9 files changed

Lines changed: 454 additions & 8 deletions

File tree

admin/notifications/views.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from mako.lexer import Lexer
1212
from mako.parsetree import ControlLine
1313
import re
14+
from string import Formatter
1415

1516
def delete_selected_notifications(selected_ids):
1617
NotificationSubscription.objects.filter(id__in=selected_ids).delete()
@@ -82,7 +83,7 @@ def generate_mock_json(structure, list_name=None):
8283
return result
8384

8485

85-
def build_safe_context(template: str) -> dict:
86+
def build_safe_context(template: str, subject: str) -> dict:
8687
templatenode = Lexer(text=template).parse()
8788
identifiers_location = []
8889
for node in templatenode.get_children():
@@ -103,6 +104,9 @@ def build_safe_context(template: str) -> dict:
103104
mock_json = generate_mock_json(identifier_structure)
104105
context = {identifier: f'mock_{identifier}' for identifier in flatten_identifiers if identifier not in TEMPLATE_IDENTIFIER_BLACKLIST}
105106
context.update(mock_json)
107+
108+
# subject
109+
context.update({key: key for _, key, _, _ in Formatter().parse(subject) if key})
106110
return context
107111

108112
class NotificationsList(PermissionRequiredMixin, ListView):
@@ -282,12 +286,12 @@ def get_context_data(self, *args, **kwargs):
282286
return kwargs
283287
else:
284288
if notification_type.is_digest_type:
285-
inner_context = build_safe_context(notification_type.template)
289+
inner_context = build_safe_context(notification_type.template, notification_type.subject)
286290
inner_template = _render_email_html(notification_type, ctx=inner_context, return_original_error=True)
287291
safe_context = {'notifications': [inner_template]}
288292
return_context = inner_context
289293
else:
290-
safe_context = build_safe_context(notification_type.template)
294+
safe_context = build_safe_context(notification_type.template, notification_type.subject)
291295
return_context = safe_context
292296

293297
if notification_type.is_digest_type:
@@ -300,6 +304,7 @@ def get_context_data(self, *args, **kwargs):
300304
except Exception as e:
301305
kwargs['rendered_template'] = f"Error rendering template: {str(e)}"
302306

307+
kwargs['rendered_subject'] = notification_type.subject.format(**safe_context)
303308
kwargs['context'] = json.dumps(return_context, indent=4)
304309

305310
return kwargs

admin/templates/notifications/notification_type_preview.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<h2>Notification Template Preview</h2>
22
<h3>Rendered Template</h3>
3+
<h3>Subject: {{ rendered_subject|safe }}</h3>
34

45

56

osf/email/notification_campaign.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import logging
2+
from osf.models import NotificationType, NotificationTypeEnum, OSFUser, UserActivityCounter
3+
from django.db.models import Q
4+
from django.db.models import OuterRef, Subquery, Exists, F
5+
from django.db.models.functions import Coalesce
6+
from framework.celery_tasks import app as celery_app
7+
from celery import chord
8+
from django.utils import timezone
9+
from osf.models.notification_campaign import NotificationCampaign, NotificationCampaignRecipient, NotificationCampaignStatus, NotificationCampaignRecipientStatus
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
counter_subquery = (
15+
UserActivityCounter.objects
16+
.filter(_id=OuterRef('guids___id'))
17+
.values('total')[:1]
18+
)
19+
20+
21+
def get_filtered_batches(filters, batch_size=1000, campaign_id=None):
22+
already_sent_subquery = NotificationCampaignRecipient.objects.filter(
23+
campaign_id=campaign_id,
24+
user_id=OuterRef('pk'),
25+
)
26+
27+
qs = (
28+
OSFUser.objects
29+
.annotate(already_sent=Exists(already_sent_subquery))
30+
.filter(already_sent=False)
31+
.filter(**filters)
32+
.annotate(activity_total=Coalesce(Subquery(counter_subquery), 0))
33+
.order_by('-activity_total', '-date_registered', '-id')
34+
)
35+
36+
last_total = None
37+
last_date = None
38+
last_id = None
39+
40+
while True:
41+
batch_qs = qs
42+
43+
if last_total is not None:
44+
batch_qs = batch_qs.filter(
45+
Q(activity_total__lt=last_total) |
46+
Q(activity_total=last_total, date_registered__lt=last_date) |
47+
Q(activity_total=last_total, date_registered=last_date, id__lt=last_id)
48+
)
49+
50+
batch = batch_qs[:batch_size]
51+
52+
if not batch:
53+
break
54+
55+
rows = list(batch.values_list('id', 'activity_total', 'date_registered'))
56+
57+
if not rows:
58+
break
59+
60+
batch_ids = [r[0] for r in rows]
61+
62+
last_id, last_total, last_date = (
63+
rows[-1][0],
64+
rows[-1][1],
65+
rows[-1][2],
66+
)
67+
68+
yield batch_ids
69+
70+
71+
FILTER_PRESETS = {
72+
'all': {},
73+
'active': {'is_active': True},
74+
'internal': {'is_active': True, 'is_staff': True, 'username__endswith': '@cos.io'},
75+
}
76+
77+
@celery_app.task(name='email.process_campaign_retry')
78+
def process_campaign_retry(*args, **kwargs):
79+
80+
campaign_id = kwargs.get('campaign_id')
81+
campaign = NotificationCampaign.objects.get(id=campaign_id)
82+
failed_recipients = NotificationCampaignRecipient.objects.filter(campaign=campaign, status=NotificationCampaignRecipientStatus.FAILED)
83+
max_retries = campaign.metadata.get('execution', {}).get('max_retries', 3)
84+
batch_size = campaign.metadata.get('execution', {}).get('batch_size', 1000)
85+
failed_recipients_count = failed_recipients.count()
86+
if not failed_recipients_count:
87+
campaign.status = NotificationCampaignStatus.COMPLETED
88+
campaign.completed_at = timezone.now()
89+
campaign.failed_count = 0
90+
campaign.save(update_fields=['status', 'completed_at', 'failed_count'])
91+
return
92+
93+
if campaign.retries < max_retries:
94+
logger.info(f'Retrying {failed_recipients_count} failed recipients for campaign {campaign_id}')
95+
filters = {'id__in': failed_recipients.values_list('user_id', flat=True)}
96+
tasks = []
97+
for batch in get_filtered_batches(filters=filters, batch_size=batch_size, campaign_id=campaign_id):
98+
tasks.append(
99+
send_campaign_batch.s(
100+
notification_type_name=campaign.notification_type.name,
101+
recipients_ids=batch,
102+
context=campaign.metadata.get('context', {}),
103+
campaign_id=campaign_id,
104+
)
105+
)
106+
chord(tasks)(
107+
process_campaign_retry.s(campaign_id=campaign_id)
108+
)
109+
campaign.retries += 1
110+
campaign.save(update_fields=['retries'])
111+
else:
112+
campaign.failed_count = failed_recipients_count
113+
campaign.status = NotificationCampaignStatus.PARTIALLY_COMPLETED
114+
campaign.completed_at = timezone.now()
115+
campaign.save(update_fields=['status', 'completed_at', 'failed_count'])
116+
117+
118+
@celery_app.task(name='email.start_notification_campaign')
119+
def start_notification_campaign(campaign_id):
120+
campaign = NotificationCampaign.objects.get(id=campaign_id)
121+
filters = campaign.metadata.get('filters', {})
122+
context = campaign.metadata.get('context', {})
123+
notification_type_name = campaign.notification_type.name
124+
125+
if hasattr(NotificationTypeEnum, notification_type_name):
126+
del getattr(NotificationTypeEnum, notification_type_name).instance
127+
128+
if predefined_filter_name := filters.get('predefined'):
129+
filters = FILTER_PRESETS.get(predefined_filter_name, {})
130+
131+
tasks = []
132+
total_recipients = 0
133+
batch_size = campaign.metadata.get('execution', {}).get('batch_size', 1000)
134+
for batch in get_filtered_batches(filters=filters, batch_size=batch_size):
135+
tasks.append(
136+
send_campaign_batch.s(
137+
notification_type_name=notification_type_name,
138+
recipients_ids=batch,
139+
context=context,
140+
campaign_id=campaign_id,
141+
)
142+
)
143+
total_recipients += len(batch)
144+
145+
campaign.recipient_count = total_recipients
146+
campaign.save(update_fields=['recipient_count'])
147+
148+
chord(tasks)(
149+
process_campaign_retry.s(campaign_id=campaign_id)
150+
)
151+
152+
153+
@celery_app.task(name='email.send_campaign_batch', ignore_result=False)
154+
def send_campaign_batch(context, recipients_ids, notification_type_name='blank', campaign_id=None):
155+
campaign = NotificationCampaign.objects.get(id=campaign_id)
156+
if campaign.status == NotificationCampaignStatus.CANCELLED:
157+
logger.warning(f"Campaign {campaign_id} was cancelled")
158+
return
159+
if hasattr(NotificationTypeEnum, notification_type_name):
160+
notification_type = getattr(NotificationTypeEnum, notification_type_name).instance
161+
else:
162+
notification_type = NotificationType.objects.filter(
163+
name=notification_type_name
164+
).first() # TODO cache
165+
166+
if notification_type is None:
167+
return
168+
169+
recipients_qs = OSFUser.objects.filter(id__in=recipients_ids)
170+
recipient_records = {
171+
'to_create': [],
172+
'to_update': [],
173+
}
174+
existing = {
175+
r.user_id: r
176+
for r in NotificationCampaignRecipient.objects.filter(
177+
campaign_id=campaign_id,
178+
user_id__in=recipients_ids,
179+
)
180+
}
181+
success_count = 0
182+
failure_count = 0
183+
184+
for recipient in recipients_qs:
185+
recipient_record = existing.get(recipient.id)
186+
187+
if recipient_record is None:
188+
recipient_record = NotificationCampaignRecipient(
189+
campaign_id=campaign_id,
190+
user=recipient,
191+
)
192+
operation = 'to_create'
193+
else:
194+
operation = 'to_update'
195+
196+
try:
197+
notification_type.emit(
198+
user=recipient,
199+
event_context=context,
200+
save=False, # Too many write operations
201+
)
202+
203+
recipient_record.status = NotificationCampaignRecipientStatus.SENT
204+
recipient_record.error_message = None
205+
recipient_records[operation].append(recipient_record)
206+
success_count += 1
207+
208+
except Exception as exc:
209+
logger.error(exc) # TODO update error
210+
recipient_record.status = NotificationCampaignRecipientStatus.FAILED
211+
recipient_record.error_message = str(exc)
212+
recipient_records[operation].append(recipient_record)
213+
214+
failure_count += 1
215+
pass
216+
217+
NotificationCampaignRecipient.objects.bulk_create(recipient_records['to_create'])
218+
NotificationCampaignRecipient.objects.bulk_update(recipient_records['to_update'], ['status', 'error_message'])
219+
220+
NotificationCampaign.objects.filter(pk=campaign_id).update(sent_count=F('sent_count') + success_count, failed_count=F('failed_count') + failure_count)
221+
logger.info('Batch finished') # TODO: add/update logs
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Generated by Django 4.2.26 on 2026-07-09 11:38
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('osf', '0044_notification_scheduled'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='NotificationCampaign',
17+
fields=[
18+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('created_at', models.DateTimeField(auto_now_add=True)),
20+
('updated_at', models.DateTimeField(auto_now=True)),
21+
('name', models.CharField(max_length=255)),
22+
('status', models.CharField(choices=[('created', 'Created'), ('running', 'Running'), ('completed', 'Completed'), ('partially_completed', 'Partially Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled'), ('ended', 'Ended')], default='created', max_length=20)),
23+
('started_at', models.DateTimeField(blank=True, null=True)),
24+
('completed_at', models.DateTimeField(blank=True, null=True)),
25+
('metadata', models.JSONField(blank=True, default=dict)),
26+
('recipient_count', models.PositiveIntegerField(default=0)),
27+
('sent_count', models.PositiveIntegerField(default=0)),
28+
('failed_count', models.PositiveIntegerField(default=0)),
29+
('retries', models.PositiveIntegerField(default=0)),
30+
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
31+
('notification_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='osf.notificationtype')),
32+
],
33+
),
34+
migrations.CreateModel(
35+
name='NotificationCampaignRecipient',
36+
fields=[
37+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
38+
('updated_at', models.DateTimeField(auto_now=True)),
39+
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('failed', 'Failed')], db_index=True, default='pending', max_length=20)),
40+
('error_message', models.TextField(blank=True, null=True)),
41+
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipients', to='osf.notificationcampaign')),
42+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
43+
],
44+
options={
45+
'unique_together': {('campaign', 'user')},
46+
},
47+
),
48+
migrations.AddField(
49+
model_name='osfuser',
50+
name='received_notification_campaigns',
51+
field=models.ManyToManyField(through='osf.NotificationCampaignRecipient', to='osf.notificationcampaign'),
52+
),
53+
]

0 commit comments

Comments
 (0)