Skip to content

Commit fd6f778

Browse files
[ENG-11702][ENG-11694][ENG-11699][ENG-11703] Notification Campaign Admin Page (#11808)
* Add Notification Campaign management features including forms, views, and templates * Add support for restarting failed notification campaigns and update progress metrics * Prevent updating recipient count on campaign restart
1 parent d1048ec commit fd6f778

10 files changed

Lines changed: 1158 additions & 36 deletions

File tree

admin/notifications/forms.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,48 @@
11
from django import forms
2-
from osf.models import NotificationType
2+
from osf.models import NotificationType, NotificationCampaign
3+
import json
34

45

56
class NotificationTypeForm(forms.ModelForm):
67
class Meta:
78
model = NotificationType
89
fields = '__all__'
10+
11+
12+
class NotificationCampaignCreateForm(forms.ModelForm):
13+
context = forms.CharField(
14+
required=False,
15+
widget=forms.Textarea(attrs={'rows': 8}),
16+
initial='{}',
17+
)
18+
19+
filters = forms.CharField(
20+
required=False,
21+
widget=forms.HiddenInput(),
22+
initial='{}',
23+
)
24+
25+
batch_size = forms.IntegerField(
26+
min_value=1,
27+
initial=1000,
28+
)
29+
30+
max_retries = forms.IntegerField(
31+
min_value=0,
32+
initial=3,
33+
)
34+
35+
class Meta:
36+
model = NotificationCampaign
37+
fields = (
38+
'name',
39+
'notification_type',
40+
)
41+
42+
def clean_context(self):
43+
value = self.cleaned_data['context'] or '{}'
44+
return json.loads(value)
45+
46+
def clean_filters(self):
47+
value = self.cleaned_data['filters'] or '{}'
48+
return json.loads(value)

admin/notifications/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,9 @@
1111
re_path(r'types_preview/(?P<pk>\d+)/$', views.NotificationTypePreview.as_view(), name='types_preview'),
1212
re_path(r'subscriptions/$', views.NotificationSubscriptionsList.as_view(), name='subscriptions_list'),
1313
re_path(r'email_tasks/$', views.EmailTasksList.as_view(), name='email_tasks_list'),
14+
re_path(r'notification_campaigns_list/$', views.NotificationCampaignsList.as_view(), name='notification_campaigns_list'),
15+
re_path(r'notification_campaigns_detail/(?P<pk>\d+)/$', views.NotificationCampaignDetail.as_view(), name='notification_campaigns_detail'),
16+
re_path(r'notification_campaigns_create/$', views.NotificationCampaignCreateView.as_view(), name='notification_campaigns_create'),
17+
re_path(r'notification_campaigns_recipients_preview/$', views.NotificationCampaignsRecipientsPreview.as_view(), name='notification_campaigns_recipients_preview'),
18+
re_path(r'notification_campaigns_start/(?P<pk>\d+)/$', views.StartNotificationCampaign.as_view(), name='notification_campaigns_start'),
1419
]

admin/notifications/views.py

Lines changed: 278 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
1+
import re
2+
import json
3+
from collections import defaultdict
14
from django.urls import reverse_lazy
25
from django.db.models import Q
3-
from osf.models import NotificationSubscription, NotificationType, Notification, EmailTask
4-
from django.views.generic import ListView, DetailView, UpdateView
6+
from django.db import models
7+
from django.shortcuts import get_object_or_404, redirect
8+
from django.views.generic import ListView, DetailView, UpdateView, CreateView, View
9+
from django.contrib import messages
510
from django.contrib.auth.mixins import PermissionRequiredMixin
11+
from osf.models import NotificationSubscription, NotificationType, Notification, EmailTask, NotificationCampaign, OSFUser
12+
from osf.models.notification_campaign import NotificationCampaignStatus
613
from django.forms.models import model_to_dict
7-
from .forms import NotificationTypeForm
8-
from osf.email import _render_email_html
9-
import json
10-
from collections import defaultdict
14+
from .forms import NotificationTypeForm, NotificationCampaignCreateForm
1115
from mako.lexer import Lexer
1216
from mako.parsetree import ControlLine
13-
import re
1417
from string import Formatter
18+
from osf.email import _render_email_html
19+
from osf.email.notification_campaign import FILTER_PRESETS, filter_users
20+
1521

1622
def delete_selected_notifications(selected_ids):
1723
NotificationSubscription.objects.filter(id__in=selected_ids).delete()
@@ -332,3 +338,268 @@ class NotificationTypeChangeForm(PermissionRequiredMixin, UpdateView):
332338

333339
def get_success_url(self, *args, **kwargs):
334340
return reverse_lazy('notifications:type_display', kwargs={'pk': self.kwargs.get('pk')})
341+
342+
343+
class NotificationCampaignsList(PermissionRequiredMixin, ListView):
344+
paginate_by = 25
345+
template_name = 'notifications/notification_campaigns_list.html'
346+
ordering = 'name'
347+
permission_required = 'osf.view_notificationcampaign'
348+
raise_exception = True
349+
model = NotificationCampaign
350+
351+
def get_queryset(self):
352+
qs = NotificationCampaign.objects.all().order_by(self.ordering)
353+
q = self.request.GET.get('q')
354+
if q:
355+
qs = qs.filter(
356+
Q(name__icontains=q) |
357+
Q(status__icontains=q) |
358+
Q(notification_type__name__icontains=q)
359+
)
360+
return qs
361+
362+
def get_context_data(self, **kwargs):
363+
context = super().get_context_data(**kwargs)
364+
q = self.request.GET.get('q', '')
365+
context['q'] = q
366+
# append search param to pagination links
367+
if q:
368+
context['extra_query_params'] = f"&q={q}"
369+
else:
370+
context['extra_query_params'] = ''
371+
372+
context['notification_campaigns'] = context['object_list']
373+
context['page'] = context['page_obj']
374+
return context
375+
376+
377+
class NotificationCampaignDetail(PermissionRequiredMixin, DetailView):
378+
model = NotificationCampaign
379+
template_name = 'notifications/notification_campaigns_detail.html'
380+
permission_required = 'osf.change_notificationcampaign'
381+
raise_exception = True
382+
383+
def get_object(self, queryset=None):
384+
return NotificationCampaign.objects.get(id=self.kwargs.get('pk'))
385+
386+
def get_context_data(self, *args, **kwargs):
387+
notification_campaign = self.get_object()
388+
metadata = notification_campaign.metadata or {}
389+
390+
context = {
391+
'notification_campaign': notification_campaign,
392+
'display_fields': [
393+
('Name', notification_campaign.name),
394+
('Notification Type', notification_campaign.notification_type),
395+
('Created By', notification_campaign.created_by),
396+
('Status', notification_campaign.get_status_display()),
397+
('Recipients', notification_campaign.recipient_count),
398+
('Sent', notification_campaign.sent_count),
399+
('Failed', notification_campaign.failed_count),
400+
('Retries', notification_campaign.retries),
401+
('Created', notification_campaign.created_at),
402+
('Started', notification_campaign.started_at),
403+
('Completed', notification_campaign.completed_at),
404+
],
405+
'template': notification_campaign.notification_type.template,
406+
'metadata': metadata,
407+
'filters_json': json.dumps(notification_campaign.metadata['filters']),
408+
'sent_filters_json': json.dumps({
409+
'manual': [
410+
{'field': 'notificationcampaignrecipient', 'value': notification_campaign.id, 'lookup': 'campaign'},
411+
{'field': 'notificationcampaignrecipient', 'value': 'sent', 'lookup': 'status'}
412+
]
413+
}),
414+
'failed_filters_json': json.dumps({
415+
'manual': [
416+
{'field': 'notificationcampaignrecipient', 'value': notification_campaign.id, 'lookup': 'campaign'},
417+
{'field': 'notificationcampaignrecipient', 'value': 'failed', 'lookup': 'status'}
418+
]
419+
}),
420+
'other_metadata': {
421+
k: v
422+
for k, v in metadata.items()
423+
if k not in {'filters', 'context', 'execution', 'template'}
424+
},
425+
}
426+
427+
if notification_campaign.status == NotificationCampaignStatus.RUNNING:
428+
context.update({
429+
'sent_percent': notification_campaign.sent_count * 100 / notification_campaign.recipient_count if notification_campaign.recipient_count else 0,
430+
'failed_percent': notification_campaign.failed_count * 100 / notification_campaign.recipient_count if notification_campaign.recipient_count else 0,
431+
})
432+
433+
return context
434+
435+
436+
LOOKUPS = {
437+
models.CharField: {
438+
'exact': 'Equals',
439+
'iexact': 'Equals (case insensitive)',
440+
'contains': 'Contains',
441+
'icontains': 'Contains (case insensitive)',
442+
'startswith': 'Starts with',
443+
'istartswith': 'Starts with (case insensitive)',
444+
'endswith': 'Ends with',
445+
'iendswith': 'Ends with (case insensitive)',
446+
'in': 'In',
447+
'isnull': 'Is empty',
448+
},
449+
models.TextField: {
450+
'exact': 'Equals',
451+
'iexact': 'Equals (case insensitive)',
452+
'contains': 'Contains',
453+
'icontains': 'Contains (case insensitive)',
454+
'startswith': 'Starts with',
455+
'istartswith': 'Starts with (case insensitive)',
456+
'endswith': 'Ends with',
457+
'iendswith': 'Ends with (case insensitive)',
458+
'isnull': 'Is empty',
459+
},
460+
models.IntegerField: {
461+
'exact': 'Equals',
462+
'gt': 'Greater than',
463+
'gte': 'Greater than or equal to',
464+
'lt': 'Less than',
465+
'lte': 'Less than or equal to',
466+
'in': 'In',
467+
'isnull': 'Is empty',
468+
},
469+
models.DateField: {
470+
'exact': 'On',
471+
'gt': 'After',
472+
'gte': 'On or after',
473+
'lt': 'Before',
474+
'lte': 'On or before',
475+
'isnull': 'Is empty',
476+
},
477+
models.DateTimeField: {
478+
'exact': 'On',
479+
'gt': 'After',
480+
'gte': 'On or after',
481+
'lt': 'Before',
482+
'lte': 'On or before',
483+
'isnull': 'Is empty',
484+
},
485+
models.BooleanField: {
486+
'exact': 'Is',
487+
},
488+
}
489+
490+
491+
class NotificationCampaignCreateView(CreateView):
492+
model = NotificationCampaign
493+
form_class = NotificationCampaignCreateForm
494+
template_name = 'notifications/notification_campaing_create.html'
495+
allowed_filters = [
496+
'is_active',
497+
'is_staff',
498+
'username',
499+
'last_login',
500+
]
501+
502+
def form_valid(self, form):
503+
form.instance.created_by = self.request.user
504+
505+
form.instance.metadata = {
506+
'filters': form.cleaned_data['filters'],
507+
'context': form.cleaned_data['context'],
508+
'execution': {
509+
'batch_size': form.cleaned_data['batch_size'],
510+
'max_retries': form.cleaned_data['max_retries'],
511+
},
512+
}
513+
514+
response = super().form_valid(form)
515+
516+
messages.success(
517+
self.request,
518+
'Notification campaign created successfully.',
519+
)
520+
521+
return response
522+
523+
def get_success_url(self):
524+
return reverse_lazy(
525+
'notifications:notification_campaigns_detail',
526+
kwargs={'pk': self.object.pk},
527+
)
528+
529+
def get_context_data(self, **kwargs):
530+
context = super().get_context_data(**kwargs)
531+
context['notification_types'] = NotificationType.objects.order_by('name')
532+
533+
filter_fields = {}
534+
for field in [f for f in OSFUser._meta.get_fields() if f.name in self.allowed_filters]:
535+
if not field.concrete:
536+
continue
537+
if type(field) not in LOOKUPS.keys():
538+
continue
539+
filter_fields[field.name] = {
540+
'label': field.verbose_name,
541+
'type': field.get_internal_type().lower(),
542+
'lookups': LOOKUPS.get(type(field), {})
543+
}
544+
context['filter_fields'] = filter_fields
545+
context['filters'] = []
546+
context['predefined_filters'] = FILTER_PRESETS.keys()
547+
return context
548+
549+
550+
class NotificationCampaignsRecipientsPreview(PermissionRequiredMixin, ListView):
551+
template_name = 'users/list.html'
552+
permission_required = 'osf.view_osfuser'
553+
raise_exception = True
554+
paginate_by = 25
555+
556+
def get_queryset(self):
557+
filters = {}
558+
raw_filters = self.request.GET.get('filters', None)
559+
if raw_filters:
560+
json_filters = json.loads(raw_filters)
561+
if predefined := json_filters.get('predefined'):
562+
filters = FILTER_PRESETS.get(predefined, {})
563+
else:
564+
filters = {
565+
f'{item["field"]}__{item["lookup"]}': item['value']
566+
for item in json_filters.get('manual', [])
567+
}
568+
569+
return filter_users(filters)
570+
571+
def get_context_data(self, **kwargs):
572+
users = self.get_queryset()
573+
574+
page_size = self.get_paginate_by(users)
575+
paginator, page, query_set, is_paginated = self.paginate_queryset(
576+
users,
577+
page_size,
578+
)
579+
# append search param to pagination links
580+
kwargs.update({'extra_query_params': f'&filters={self.request.GET.get("filters")}'})
581+
return super().get_context_data(
582+
**kwargs,
583+
page=page,
584+
users=query_set,
585+
paginator=paginator,
586+
is_paginated=is_paginated,
587+
)
588+
589+
class StartNotificationCampaign(PermissionRequiredMixin, View):
590+
permission_required = 'osf.change_notificationtype'
591+
592+
def post(self, request, *args, **kwargs):
593+
notification_campaign = get_object_or_404(
594+
NotificationCampaign,
595+
pk=kwargs['pk'],
596+
)
597+
598+
restart_failed = request.GET.get('restart_failed') == 'true'
599+
600+
notification_campaign.start(restart_failed=restart_failed)
601+
602+
return redirect(
603+
'notifications:notification_campaigns_detail',
604+
pk=notification_campaign.pk,
605+
)

admin/templates/base.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@
289289
</div>
290290
{% endif %}
291291
{% endif %}
292-
{% if perms.osf.view_notification or perms.osf.view_notificationtype or perms.osf.view_notificationsubscription %}
292+
{% if perms.osf.view_notification or perms.osf.view_notificationtype or perms.osf.view_notificationsubscription or perms.osf.view_notificationcampaign %}
293293
<li><a role="button" data-toggle="collapse" href="#collapseNotifications">
294294
<i class='fa fa-caret-down'></i> Notifications
295295
</a></li>
@@ -307,6 +307,9 @@
307307
{% if perms.osf.view_emailtask %}
308308
<li><a href="{% url 'notifications:email_tasks_list' %}"><i class='fa fa-link'></i> <span>Email Tasks</span></a></li>
309309
{% endif %}
310+
{% if perms.osf.view_notificationcampaign %}
311+
<li><a href="{% url 'notifications:notification_campaigns_list' %}"><i class='fa fa-link'></i> <span>Notification Campaigns</span></a></li>
312+
{% endif %}
310313

311314
</ul>
312315
</div>

0 commit comments

Comments
 (0)