Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bbec70e
[ENG-12171] Show queued in status list / progress bar (#11919)
antkryt Sep 17, 2026
f7edff9
[ENG-12197] Add a dedicated status for Bulk Send "Queued" (w/ migrati…
antkryt Sep 21, 2026
a3766fb
[ENG-12214] Handle different SendGrid failures (#11935)
antkryt Sep 25, 2026
a999563
[ENG-12198] Apply SendGrid webhook for non-bulk send- #11939 (#11939)
Ostap-Zherebetskyi Sep 28, 2026
4276f50
[ENG-12315] Remove queued_count field and alert awaiting-delivery-onl…
antkryt Sep 28, 2026
e0603af
[ENG-12215] Some sentry messages / error logs should also inform PO v…
Ostap-Zherebetskyi Sep 28, 2026
456415c
[ENG-12305] Handle bulk retries and restart stuck properly (#11944)
antkryt Sep 29, 2026
f51f788
[ENG-12334] Split tasks for readability and maintainability (#11950)
antkryt Oct 2, 2026
362b857
Merge remote-tracking branch 'upstream/develop' into feature/post-ent…
cslzchen Oct 2, 2026
462d3c8
Redo migration for post-enter-p2
cslzchen Oct 2, 2026
90feb88
Merge pull request #11955 from cslzchen/feature/post-enter-p2
cslzchen Oct 2, 2026
8669d4f
merge develop; fix merge conflicts
antkryt Oct 9, 2026
466acda
Merge pull request #11966 from antkryt/feature/post-enter-p2
cslzchen Oct 9, 2026
1ffc874
trigger push webhook
cslzchen Oct 12, 2026
0c2504d
use live counters on campaign detail page; remove sync campaign stats…
antkryt Oct 13, 2026
7b149b3
remove event parsing
antkryt Oct 13, 2026
33a17cb
fix tests
antkryt Oct 13, 2026
ba2d26d
Merge pull request #11969 from antkryt/fix/ENG-12461
cslzchen Oct 13, 2026
90d9e9c
Merge remote-tracking branch 'upstream/develop' into feature/post-ent…
cslzchen Oct 28, 2026
aae2640
Merge migration
cslzchen Oct 28, 2026
c500492
[ENG-12603] Split API keys between standard notifications and ENTER o…
antkryt Nov 2, 2026
a45311d
[ENG-12583] Update bulk and non-bulk settings accordingly to prevent …
antkryt Nov 4, 2026
e987d3a
[ENG-12645][ENG-12647] Fix campaign stuck status not triggered due to…
antkryt Nov 4, 2026
3450cb9
Merge remote-tracking branch 'upstream/develop' into feature/post-ent…
cslzchen Nov 5, 2026
078d95f
Create merge migration, ignorning conflict warning
cslzchen Nov 5, 2026
845b308
[ENG-12657] Support: Update command to create more users for ENTER (#…
cslzchen Nov 6, 2026
81eff74
[ENG-12692] Fix and rework admin permission check tests for notificat…
cslzchen Nov 8, 2026
0bddd59
[ENG-12646] Split SKIPPED and FAILED into two separate tracking in ca…
antkryt Nov 9, 2026
1324141
[ENG-12648] Add extra / finer-grained sentry and server logs for ENT…
cslzchen Nov 10, 2026
0a07a24
[ENG-12649] Add more campaign status emails (#12020)
antkryt Nov 11, 2026
9f1fad7
Merge remote-tracking branch 'upstream/develop' into feature/post-ent…
cslzchen Nov 11, 2026
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
6 changes: 6 additions & 0 deletions admin/notifications/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ class NotificationCampaignCreateForm(forms.ModelForm):
help_text='The time in seconds between dispatches of campaign batches.',
)

log_email_recipients = forms.CharField(
required=False,
initial='',
help_text='List of email addresses to receive campaign log emails, separated by commas.',
)

class Meta:
model = NotificationCampaign
fields = (
Expand Down
42 changes: 37 additions & 5 deletions admin/notifications/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from mako.parsetree import ControlLine
from string import Formatter
from osf.email import _render_email_html
from osf.email.notification_campaign import FILTER_PRESETS, counter_subquery, build_campaign_filter_query
from osf.email.notification_campaign import FILTER_PRESETS, counter_subquery, build_campaign_filter_query, get_campaign_recipient_stats
from website import settings
from urllib.parse import urlencode

Expand Down Expand Up @@ -392,6 +392,9 @@ def get_object(self, queryset=None):
def get_context_data(self, *args, **kwargs):
notification_campaign = self.get_object()
metadata = notification_campaign.metadata or {}
stats = get_campaign_recipient_stats(notification_campaign.id)
queued = stats['queued_count']
awaiting = stats['awaiting_count']

context = {
'notification_campaign': notification_campaign,
Expand All @@ -403,6 +406,8 @@ def get_context_data(self, *args, **kwargs):
('Status', notification_campaign.get_status_display()),
('Recipients', notification_campaign.recipient_count),
('Sent', notification_campaign.sent_count),
('Queued', queued),
('Awaiting Delivery', awaiting),
('Failed', notification_campaign.failed_count),
('Retries', notification_campaign.retries),
('Created', notification_campaign.created_at),
Expand Down Expand Up @@ -437,7 +442,16 @@ def get_context_data(self, *args, **kwargs):

if notification_campaign.status != NotificationCampaignStatus.CREATED:
processed = notification_campaign.sent_count + notification_campaign.failed_count
pending = max(notification_campaign.recipient_count - processed, 0)
pending = max(
notification_campaign.recipient_count - processed - queued - awaiting,
0,
)
awaiting_delivery = (
notification_campaign.status == NotificationCampaignStatus.RUNNING
and pending == 0
and queued == 0
and awaiting > 0
)

sent_percent = (
notification_campaign.sent_count / notification_campaign.recipient_count * 100
Expand All @@ -447,8 +461,19 @@ def get_context_data(self, *args, **kwargs):
notification_campaign.failed_count / notification_campaign.recipient_count * 100
if notification_campaign.recipient_count else 0
)
queued_percent = (
queued / notification_campaign.recipient_count * 100
if notification_campaign.recipient_count else 0
)
awaiting_percent = (
awaiting / notification_campaign.recipient_count * 100
if notification_campaign.recipient_count else 0
)

pending_percent = max(100 - sent_percent - failed_percent, 0)
pending_percent = max(
100 - sent_percent - failed_percent - queued_percent - awaiting_percent,
0,
)
elapsed = None
speed = None
eta = None
Expand All @@ -462,8 +487,9 @@ def get_context_data(self, *args, **kwargs):
elapsed_seconds = elapsed.total_seconds()
if processed > 0 and elapsed_seconds > 0:
speed = processed / elapsed_seconds
if pending:
eta = timedelta(seconds=int(pending / speed))
remaining = pending + queued
if remaining:
eta = timedelta(seconds=int(remaining / speed))
estimated_finish = timezone.now() + eta

if notification_campaign.updated_at:
Expand All @@ -476,8 +502,13 @@ def get_context_data(self, *args, **kwargs):
context.update({
'processed': processed,
'pending': pending,
'queued': queued,
'awaiting': awaiting,
'awaiting_delivery': awaiting_delivery,
'sent_percent': sent_percent,
'failed_percent': failed_percent,
'queued_percent': queued_percent,
'awaiting_percent': awaiting_percent,
'pending_percent': pending_percent,
'elapsed': elapsed,
'speed': speed,
Expand Down Expand Up @@ -585,6 +616,7 @@ def form_valid(self, form):
'time_window': form.cleaned_data['time_window'],
'max_queued_batches': form.cleaned_data['max_queued_batches'],
'dispatch_interval': form.cleaned_data['dispatch_interval'],
'log_email_recipients': form.cleaned_data['log_email_recipients'],
},
'sendgrid_bulk': form.cleaned_data.get('sendgrid_bulk', False),
'recipients_creation_finished': False,
Expand Down
27 changes: 27 additions & 0 deletions admin/templates/notifications/notification_campaigns_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,30 @@ <h2 id="campaign_name">{{ notification_campaign.name }}</h2>
<strong>Last activity:</strong> {{ last_activity_ago }} ago
{% endif %}
</div>
{% if awaiting_delivery %}
<div class="alert alert-info">
<strong>Awaiting delivery:</strong>
All recipients have been submitted to SendGrid.
Waiting for delivery confirmation ({{ awaiting }} awaiting).
</div>
{% endif %}
<div class="progress">

<div class="progress-bar progress-bar-success"
style="width: {{ sent_percent }}%;">
{{ notification_campaign.sent_count }}
</div>

<div class="progress-bar progress-bar-info"
style="width: {{ awaiting_percent }}%;">
{{ awaiting }}
</div>

<div class="progress-bar progress-bar-warning"
style="width: {{ queued_percent }}%;">
{{ queued }}
</div>

<div class="progress-bar progress-bar-danger"
style="width: {{ failed_percent }}%;">
{{ notification_campaign.failed_count }}
Expand All @@ -88,6 +105,16 @@ <h4>Progress</h4>
<td>{{ processed }} / {{ notification_campaign.recipient_count }}</td>
</tr>

<tr>
<th>Queued</th>
<td>{{ queued }}</td>
</tr>

<tr>
<th>Awaiting Delivery</th>
<td>{{ awaiting }}</td>
</tr>

<tr>
<th>Pending</th>
<td>{{ pending }}</td>
Expand Down
14 changes: 14 additions & 0 deletions admin/templates/notifications/notification_campaing_create.html
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,20 @@ <h4>Execution</h4>
</p>
</td>
</tr>
<tr>
<th>Log Email Recipients</th>
<td>
<input
class="form-control"
type="text"
name="log_email_recipients"
value="{{ form.log_email_recipients.initial }}"
>
<p class="help-block">
{{ form.log_email_recipients.help_text }}
</p>
</td>
</tr>
</table>
</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions admin_tests/notifications/test_campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def _valid_form_data(notification_type, **overrides):
'time_window': 8 * 60 * 60,
'max_queued_batches': settings.MAX_QUEUED_CAMPAIGN_BATCHES,
'dispatch_interval': settings.CAMPAIGN_DISPATCH_INTERVAL,
'log_email_recipients': '',
}
data.update(overrides)
return data
Expand All @@ -81,6 +82,7 @@ def test_valid_form_parses_context_and_filters(self, notification_type):
assert form.cleaned_data['activity_threshold'] == settings.DEFAULT_CAMPAIGN_ACTIVITY_THRESHOLD
assert form.cleaned_data['time_window'] == 8 * 60 * 60
assert form.cleaned_data['sendgrid_bulk'] is False
assert form.cleaned_data['log_email_recipients'] == ''

def test_defaults_come_from_settings(self):
form = NotificationCampaignCreateForm()
Expand Down Expand Up @@ -288,6 +290,7 @@ def test_form_valid_persists_execution_metadata(self):
'time_window': 28800,
'max_queued_batches': 10,
'dispatch_interval': 60,
'log_email_recipients': ''
}
assert campaign.metadata['sendgrid_bulk'] is True
assert campaign.metadata['filters'] == {'predefined': 'active'}
Expand Down
4 changes: 3 additions & 1 deletion api/sendgrid/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ class SendGridEventWebhook(APIView):
- Enable Signed Event Webhook; copy the verification key into
``SENDGRID_EVENT_WEBHOOK_PUBLIC_KEY`` (``website/settings/local.py``).
- Subscribe at least to ``delivered``, ``bounce``, and ``dropped``.
(``deferred`` is safe to enable but ignored for campaigns - SendGrid
retries those itself.)
4. Send a notification campaign email (personalization ``custom_args`` must
include ``campaign_id``, ``campaign_recipient_id``, and ``run_id``).
include ``campaign_id``, ``campaign_recipient_id``, and ``sent_at``).
SendGrid's "Test Your Integration" payload lacks those keys, so this view
accepts it with 200 but does not update campaign recipients.
"""
Expand Down
7 changes: 7 additions & 0 deletions notifications.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -828,3 +828,10 @@ notification_types:
object_content_type_model_name: osfuser
template: 'website/templates/blank.html.mako'
tests: []

- name: notification_campaign_log
subject: 'Notification campaign "{campaign_name}" — {status}'
__docs__: ...
object_content_type_model_name: osfuser
template: 'website/templates/notification_campaign_log.html.mako'
tests: []
55 changes: 55 additions & 0 deletions osf/email/notification_campaign/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from .base import NotificationCampaignTask
from .execution import (
dispatch_campaign,
process_campaign_retry,
send_campaign_batch,
start_notification_campaign,
)
from .recipients import (
BULK_CREATE_SIZE,
FILTER_PRESETS,
assign_batch_id_to_recipients,
build_campaign_filter_query,
build_query,
counter_subquery,
create_campaign_recipients,
first_email_subquery,
get_campaign_recipient_stats,
)
from .webhook import (
CAMPAIGN_CUSTOM_ARG_KEYS,
SENDGRID_HARD_BOUNCE_TYPE,
SENDGRID_SKIP_EVENTS,
SENDGRID_SOFT_BOUNCE_TYPE,
SENDGRID_SUCCESS_EVENTS,
_classify_sendgrid_events,
_classify_sendgrid_failure,
_sendgrid_event_error_message,
process_sendgrid_campaign_events,
)

__all__ = [
'BULK_CREATE_SIZE',
'CAMPAIGN_CUSTOM_ARG_KEYS',
'FILTER_PRESETS',
'SENDGRID_HARD_BOUNCE_TYPE',
'SENDGRID_SKIP_EVENTS',
'SENDGRID_SOFT_BOUNCE_TYPE',
'SENDGRID_SUCCESS_EVENTS',
'NotificationCampaignTask',
'_classify_sendgrid_events',
'_classify_sendgrid_failure',
'_sendgrid_event_error_message',
'assign_batch_id_to_recipients',
'build_campaign_filter_query',
'build_query',
'counter_subquery',
'create_campaign_recipients',
'dispatch_campaign',
'first_email_subquery',
'get_campaign_recipient_stats',
'process_campaign_retry',
'process_sendgrid_campaign_events',
'send_campaign_batch',
'start_notification_campaign',
]
70 changes: 70 additions & 0 deletions osf/email/notification_campaign/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import logging

from django.utils import timezone

from framework import sentry
from framework.celery_tasks import app as celery_app
from osf.email import send_email
from osf.models import NotificationTypeEnum
from osf.models.notification_campaign import (
NotificationCampaign,
NotificationCampaignStatus,
)

from .recipients import get_campaign_recipient_stats

logger = logging.getLogger(__name__)


class NotificationCampaignTask(celery_app.Task):
"""Shared guards for notification campaign Celery tasks."""

abstract = True

def get_campaign(self, campaign_id, run_id=None, *, abort_if_cancelled=True):
"""Load a campaign, or return None if the task should no-op.
"""
campaign = NotificationCampaign.objects.get(id=campaign_id)
if run_id is not None and campaign.run_id != run_id:
return None
if abort_if_cancelled and campaign.status == NotificationCampaignStatus.CANCELLED:
logger.warning(f"Campaign {campaign_id} was cancelled")
return None
return campaign

def sync_campaign_stats(self, campaign, *, save=True):
stats = get_campaign_recipient_stats(campaign.id)
campaign.recipient_count = stats['recipient_count']
campaign.sent_count = stats['sent_count']
campaign.failed_count = stats['failed_count']
if save:
campaign.save(update_fields=[
'recipient_count',
'sent_count',
'failed_count',
'updated_at',
])
return stats

def finish_campaign(self, campaign, status=None):
"""Sync recipient counters, set completed_at once, optionally update status, and save."""
self.sync_campaign_stats(campaign, save=False)
if campaign.completed_at is None:
campaign.completed_at = timezone.now()
if status is not None:
campaign.status = status
campaign.save()
self.send_campaign_log_email(campaign, status or campaign.status, f"Campaign finished with status {status or campaign.status}")

def send_campaign_log_email(self, campaign, status, message):
"""Send a notification campaign status email."""
recipients = [recipient.strip() for recipient in campaign.metadata['execution'].get('log_email_recipients', '').split(',') if recipient]
for recipient in recipients:
try:
send_email(
recipient_address=recipient,
notification_type=NotificationTypeEnum.NOTIFICATION_CAMPAIGN_LOG.instance,
event_context={'campaign_name': campaign.name, 'status': status, 'message': message},)
except Exception as exc:
logger.error(f"Failed to send error logs email for campaign {campaign.id}: {exc}")
sentry.log_message(f"Failed to send error logs email for campaign {campaign.id}: {exc}")
Loading
Loading