Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3de4f84
developer warning only for high-activity phase
antkryt Aug 6, 2026
204be1a
[ENG-11936] Time window / developer reminder applies only to `high_ac…
Ostap-Zherebetskyi Aug 13, 2026
f2cafd1
[ENG-11968] Fix 'updated_at' field not being updated (#11867)
Ostap-Zherebetskyi Aug 25, 2026
ec29e9f
[ENG-11874] Create a periodic task to clean up recipients (#11869)
Ostap-Zherebetskyi Aug 26, 2026
e0fe6c1
[ENG-11873] Delete test campaigns (#11877)
Ostap-Zherebetskyi Aug 26, 2026
97ce6db
[ENG-11997] Exclude Unconfirmed Accounts from Email Campaigns (#11878)
antkryt Aug 31, 2026
0039517
[ENG-12046] Fix sendgrid bulk personalization (#11882)
antkryt Aug 31, 2026
03f47c9
[ENG-11995] Add a bounded dispatcher for Celery tasks (#11886)
Ostap-Zherebetskyi Sep 1, 2026
5ecb2e8
Merge remote-tracking branch 'upstream/develop' into feature/post-ent…
cslzchen Sep 1, 2026
b608317
[ENG-12079] Make dispatcher settings configurable through admin app f…
Ostap-Zherebetskyi Sep 2, 2026
a9a6d36
[ENG-12090] Make recipients creation a separate step/task (#11898)
Ostap-Zherebetskyi Sep 4, 2026
1716844
Merge remote-tracking branch 'upstream/develop' into feature/post-ent…
cslzchen Sep 8, 2026
588aebb
Re-do migration after merge release 26.20
cslzchen Sep 8, 2026
ffda8a8
[ENG-12138] Bypass notification.emit() and use sendgrid directly to r…
Ostap-Zherebetskyi Sep 9, 2026
59afb3e
ENG-12152] Fix admin progress data (#11909)
Ostap-Zherebetskyi Sep 10, 2026
9b7fc50
[ENG-12091] Implement POC error handling/monitoring for SendGrid bulk…
antkryt Sep 11, 2026
799d33c
Merge remote-tracking branch 'upstream/develop' into feature/merge-de…
cslzchen Sep 11, 2026
ddc3d49
Redo post-ENTER migration after 26.21 merge
cslzchen Sep 11, 2026
cca6004
Merge pull request #11915 from cslzchen/feature/merge-develop-and-red…
cslzchen Sep 11, 2026
cee5672
Update recipient count logic in create_campaign_recipients to reflect…
Ostap-Zherebetskyi Sep 15, 2026
3830693
Merge pull request #11920 from Ostap-Zherebetskyi/fix/recipients_crea…
cslzchen Sep 15, 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
15 changes: 15 additions & 0 deletions admin/notifications/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ class NotificationCampaignCreateForm(forms.ModelForm):
sendgrid_bulk = forms.BooleanField(
required=False,
initial=False,
help_text=(
'SendGrid caps personalizations at about 1000 per request - keep Batch Size ≤ 1000 for bulk requests'
),
)

max_queued_batches = forms.IntegerField(
min_value=1,
initial=settings.MAX_QUEUED_CAMPAIGN_BATCHES,
help_text='Maximum number of queued campaign batches allowed before new batches are rejected.',
)

dispatch_interval = forms.IntegerField(
min_value=1,
initial=settings.CAMPAIGN_DISPATCH_INTERVAL,
help_text='The time in seconds between dispatches of campaign batches.',
)

class Meta:
Expand Down
2 changes: 2 additions & 0 deletions admin/notifications/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@
re_path(r'notification_campaigns_recipients_preview/$', views.NotificationCampaignsRecipientsPreview.as_view(), name='notification_campaigns_recipients_preview'),
re_path(r'notification_campaigns_recipients_list/$', views.NotificationCampaignsRecipientsView.as_view(), name='notification_campaigns_recipients_list'),
re_path(r'notification_campaigns_start/(?P<pk>\d+)/$', views.StartNotificationCampaign.as_view(), name='notification_campaigns_start'),
re_path(r'notification_campaigns_create_recipients/(?P<pk>\d+)/$', views.CreateNotificationCampaignRecipients.as_view(), name='notification_campaigns_create_recipients'),
re_path(r'notification_campaigns_delete/(?P<pk>\d+)/$', views.DeleteNotificationCampaign.as_view(), name='notification_campaigns_delete'),
]
50 changes: 45 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_query
from osf.email.notification_campaign import FILTER_PRESETS, counter_subquery, build_campaign_filter_query
from website import settings
from urllib.parse import urlencode

Expand Down Expand Up @@ -431,6 +431,8 @@ def get_context_data(self, *args, **kwargs):
if k not in {'filters', 'context', 'execution', 'template'}
},
'allow_restart_stuck': True if timezone.now() - notification_campaign.updated_at > timedelta(minutes=15) else False,
'delete_allowed': notification_campaign.status != NotificationCampaignStatus.RUNNING,
'start_allowed': notification_campaign.status == NotificationCampaignStatus.CREATED and metadata.get('recipients_creation_finished', False),
}

if notification_campaign.status != NotificationCampaignStatus.CREATED:
Expand Down Expand Up @@ -581,8 +583,11 @@ def form_valid(self, form):
'max_retries': form.cleaned_data['max_retries'],
'activity_threshold': form.cleaned_data['activity_threshold'],
'time_window': form.cleaned_data['time_window'],
'max_queued_batches': form.cleaned_data['max_queued_batches'],
'dispatch_interval': form.cleaned_data['dispatch_interval'],
},
'sendgrid_bulk': form.cleaned_data.get('sendgrid_bulk', False),
'recipients_creation_finished': False,
}
try:
_render_email_html(form.instance.notification_type, form.cleaned_data['context'])
Expand Down Expand Up @@ -641,10 +646,7 @@ def get_queryset(self):
raw_filters = self.request.GET.get('filters', None)
if raw_filters:
json_filters = json.loads(raw_filters)
if predefined := json_filters.get('predefined'):
query = Q(**FILTER_PRESETS.get(predefined, {}))
else:
query = build_query(json_filters.get('manual'))
query = build_campaign_filter_query(json_filters)

qs = OSFUser.objects.filter(query)
qs = qs.annotate(
Expand Down Expand Up @@ -748,3 +750,41 @@ def post(self, request, *args, **kwargs):
'notifications:notification_campaigns_detail',
pk=notification_campaign.pk,
)

class CreateNotificationCampaignRecipients(PermissionRequiredMixin, View):
permission_required = 'osf.change_notificationcampaign'

def post(self, request, *args, **kwargs):
notification_campaign = get_object_or_404(
NotificationCampaign,
pk=kwargs['pk'],
)

if notification_campaign.status != NotificationCampaignStatus.CREATED:
messages.error(request, 'Recipients can only be created for campaigns in CREATED status.')
return redirect(
'notifications:notification_campaigns_detail',
pk=notification_campaign.pk,
)

notification_campaign.create_recipients()

return redirect(
'notifications:notification_campaigns_detail',
pk=notification_campaign.pk,
)

class DeleteNotificationCampaign(PermissionRequiredMixin, View):
permission_required = 'osf.delete_notificationcampaign'

def post(self, request, *args, **kwargs):
notification_campaign = get_object_or_404(
NotificationCampaign,
pk=kwargs['pk'],
)
if notification_campaign.status == NotificationCampaignStatus.RUNNING:
messages.error(request, 'Cannot delete a running campaign.')
return redirect('notifications:notification_campaigns_detail', pk=notification_campaign.pk)
notification_campaign.delete()
messages.success(request, f'Notification campaign {notification_campaign.name} deleted successfully.')
return redirect('notifications:notification_campaigns_list')
53 changes: 39 additions & 14 deletions admin/templates/notifications/notification_campaigns_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ <h4>Progress</h4>
{% if notification_campaign.developer_reminder_sent %}
<div class="alert alert-warning">
<strong>Warning!</strong>
The campaign exceeded the expected timeframe ({{ metadata.execution.time_window }}s). A reminder was sent.
The high-activity phase exceeded the expected timeframe ({{ metadata.execution.time_window }}s). A reminder was sent.
</div>
{% endif %}
{% endif %}
Expand All @@ -169,10 +169,21 @@ <h4>Progress</h4>
style="display:inline;"
>
{% csrf_token %}
<button type="submit" class="btn btn-primary" {% if notification_campaign.status != "created" %}disabled{% endif %}>
<button type="submit" class="btn btn-primary" {% if not start_allowed %}disabled{% endif %}>
Start Campaign
</button>
</form>
<form
id="create-campaign-recipients-form"
method="post"
action="{% url 'notifications:notification_campaigns_create_recipients' notification_campaign.id %}"
style="display:inline;"
>
{% csrf_token %}
<button type="submit" class="btn btn-primary" {% if notification_campaign.status != "created" %}disabled{% endif %}>
Create Campaign Recipients
</button>
</form>
<form
id="restart-stuck-campaign-form"
method="post"
Expand All @@ -197,6 +208,18 @@ <h4>Progress</h4>
Cancel Campaign
</button>
</form>
<form
id="delete-campaign-form"
method="post"
action="{% url 'notifications:notification_campaigns_delete' notification_campaign.id %}"
style="display:inline;"
>
{% csrf_token %}
<button type="submit" class="btn btn-primary" {% if not delete_allowed %}disabled{% endif %}
title="Delete the campaign and all associated data.">
Delete Campaign
</button>
</form>

<!-- General information -->
<div class="row">
Expand Down Expand Up @@ -267,25 +290,19 @@ <h4>General</h4>
<div class="col-md-12">
<h4>Recipient Filters</h4>

{% if not "predefined" in metadata.filters %}

{% if not "predefined" in metadata.filters %}
{% if metadata.filters.manual %}
{% include "notifications/campaign_filter_group.html" with group=metadata.filters.manual is_root=True %}
{% else %}
<p class="text-muted">No filters configured.</p>
{% endif %}
{% endif %}

{% elif "predefined" in metadata.filters %}

{% if "predefined" in metadata.filters %}
<table class="table table-bordered">
<tr>
<th style="width:250px;">Predefined Filter</th>
<td>{{ metadata.filters.predefined }}</td>
</tr>
</table>
{% endif %}

{% if metadata.filters.manual %}
{% include "notifications/campaign_filter_group.html" with group=metadata.filters.manual is_root=True %}
{% elif not "predefined" in metadata.filters %}
<p class="text-muted">No filters configured.</p>
{% endif %}

<a
Expand Down Expand Up @@ -451,5 +468,13 @@ <h4>Additional Metadata</h4>
});
}

const deleteForm = document.getElementById("delete-campaign-form");

if (deleteForm) {
deleteForm.addEventListener("submit", function (e) {
confirmCampaign(this, e);
});
}

</script>
{% endblock %}
10 changes: 10 additions & 0 deletions admin/templates/notifications/notification_campaigns_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@
<title>List of Notification Campaigns</title>
{% endblock title %}
{% block content %}
<div>
{% if messages %}
<ul>
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<h2>List of Notification Campaigns</h2>
{% if active_campaign %}
<h3>Active Campaign</h3>
Expand Down
70 changes: 67 additions & 3 deletions admin/templates/notifications/notification_campaing_create.html
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,20 @@ <h4>Recipient Filters</h4>
</select>
</div>

<div class="form-group" style="margin-top:15px;">
<label>
<input
type="checkbox"
id="exclude-unconfirmed"
>
Exclude unconfirmed accounts
</label>
<p class="help-block">
When enabled, users who have not confirmed their accounts
are excluded from the recipient list.
</p>
</div>

<input
type="hidden"
id="filters-input"
Expand Down Expand Up @@ -264,6 +278,34 @@ <h4>Execution</h4>
name="sendgrid_bulk"
value="{{ form.sendgrid_bulk.initial }}"
>
<p class="help-block">
{{ form.sendgrid_bulk.help_text }}
</p>
</td>
</tr>
<tr>
<th>Max Queued Batches</th>
<td>
<input
class="form-control"
type="text"
name="max_queued_batches"
value="{{ form.max_queued_batches.initial }}"
>
</td>
</tr>
<tr>
<th>Dispatch Interval</th>
<td>
<input
class="form-control"
type="text"
name="dispatch_interval"
value="{{ form.dispatch_interval.initial }}"
>
<p class="help-block">
{{ form.dispatch_interval.help_text }}
</p>
</td>
</tr>
</table>
Expand Down Expand Up @@ -406,15 +448,37 @@ <h4>Execution</h4>
}

function buildFilters() {
let filters;

if (filterMode.value === "predefined") {
return {
filters = {
"predefined": document.getElementById("filter-row").value
};
} else {
const root = builder.querySelector(":scope > .group");
filters = {"manual": serializeGroup(root)};
}

if (document.getElementById("exclude-unconfirmed").checked) {
const confirmed = {
field: "date_confirmed",
lookup: "isnull",
value: false,
};
if (filters.manual) {
filters.manual = {
operator: "AND",
children: [filters.manual, confirmed],
};
} else {
filters.manual = {
operator: "AND",
children: [confirmed],
};
}
}

const root = builder.querySelector(":scope > .group");
return {"manual": serializeGroup(root)}
return filters;
}

function updateFiltersInput() {
Expand Down
41 changes: 39 additions & 2 deletions admin_tests/notifications/test_campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
NotificationCampaignDetail,
NotificationCampaignsList,
StartNotificationCampaign,
DeleteNotificationCampaign,
)
from admin_tests.utilities import setup_form_view
from osf.models import NotificationType
Expand Down Expand Up @@ -60,6 +61,8 @@ def _valid_form_data(notification_type, **overrides):
'activity_threshold': settings.DEFAULT_CAMPAIGN_ACTIVITY_THRESHOLD,
'sendgrid_bulk': False,
'time_window': 8 * 60 * 60,
'max_queued_batches': settings.MAX_QUEUED_CAMPAIGN_BATCHES,
'dispatch_interval': settings.CAMPAIGN_DISPATCH_INTERVAL,
}
data.update(overrides)
return data
Expand Down Expand Up @@ -260,7 +263,9 @@ def test_form_valid_persists_execution_metadata(self):
max_retries=4,
activity_threshold=77,
sendgrid_bulk=True,
time_window=28800
time_window=28800,
max_queued_batches=10,
dispatch_interval=60,
),
)
request.user = self.user
Expand All @@ -282,7 +287,9 @@ def test_form_valid_persists_execution_metadata(self):
'batch_size': 25,
'max_retries': 4,
'activity_threshold': 77,
'time_window': 28800
'time_window': 28800,
'max_queued_batches': 10,
'dispatch_interval': 60,
}
assert campaign.metadata['sendgrid_bulk'] is True
assert campaign.metadata['filters'] == {'predefined': 'active'}
Expand Down Expand Up @@ -419,3 +426,33 @@ def test_start_rejects_when_another_campaign_is_running(self):
mock_start.assert_not_called()
self.campaign.refresh_from_db()
assert self.campaign.status == NotificationCampaignStatus.CREATED

class TestNotificationCampaignDeleteView(AdminTestCase):

def setUp(self):
super().setUp()
self.user = AuthUserFactory()
self.notification_type, _ = NotificationType.objects.get_or_create(
name='blank',
defaults={'subject': 'Test', 'template': 'Hello'},
)
self.campaign = NotificationCampaign.objects.create(
name='Campaign',
notification_type=self.notification_type,
metadata={'filters': {}, 'context': {}, 'execution': {}},
)

def test_delete_requires_change_notificationcampaign_permission(self):
request = RequestFactory().post(
reverse('notifications:notification_campaigns_delete', kwargs={'pk': self.campaign.pk})
)
request.user = self.user
patch_messages(request)

with self.assertRaises(PermissionDenied):
DeleteNotificationCampaign.as_view()(request, pk=self.campaign.pk)

grant_permission(self.user, 'delete_notificationcampaign')
response = DeleteNotificationCampaign.as_view()(request, pk=self.campaign.pk)
assert response.status_code == 302
assert not NotificationCampaign.objects.filter(pk=self.campaign.pk).exists()
Loading
Loading