Skip to content

Commit f99aa6d

Browse files
authored
Sponsorships notifications (#1869)
* Sort apps by name * Add missing migration after help text change * Add new app to handle custom email dispatching * Add new model to configure sponsor notifications * Minimal admin * Update admin form to validate content as django template * Add button to preview how template will render * Add new benefit configuration to flag email targeatable * Add method to filter sponsorships by included features * Enable user to select which notification template to use * Rename variable * Display warning message if selected sponsorships aren't targetable * Introduce indirection with use case to send the emails * Implement method to create a EmailMessage from a notification template * Display non targetable sponsorship as checkbox instead of text * Add select all/delete all links * Filter emails by benefits, not feature configuration * Better display for notification objects * Add checkbox to select contact type * Update get_message method to accept boolean flags to control recipients * Rename form field name * Send notification to sponsors * Register email dispatch with admin log entry activity * Add input for custom email content * Display input for custom email content * UC expects sponsorship object, not PK * Consider email subject as a template as well * Refactor to move specific email building part to mailing app * Remove warning message * Optimizes sponsorship admin query * Add option to preview notification * Fix parameters names
1 parent 9dc7cbe commit f99aa6d

27 files changed

+934
-36
lines changed

mailing/__init__.py

Whitespace-only changes.

mailing/admin.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from django.contrib import admin
2+
from django.forms.models import modelform_factory
3+
from django.http import HttpResponse
4+
from django.urls import path
5+
from django.shortcuts import get_object_or_404
6+
7+
from mailing.forms import BaseEmailTemplateForm
8+
9+
10+
class BaseEmailTemplateAdmin(admin.ModelAdmin):
11+
change_form_template = "mailing/admin/base_email_template_form.html"
12+
list_display = ["internal_name", "subject"]
13+
readonly_fields = ["created_at", "updated_at"]
14+
search_fields = ["internal_name"]
15+
fieldsets = (
16+
(None, {
17+
'fields': ('internal_name',)
18+
}),
19+
('Email template', {
20+
'fields': ('subject', 'content')
21+
}),
22+
('Timestamps', {
23+
'classes': ('collapse',),
24+
'fields': ('created_at', 'updated_at'),
25+
}),
26+
)
27+
28+
def get_form(self, *args, **kwargs):
29+
kwargs["form"] = modelform_factory(self.model, form=BaseEmailTemplateForm)
30+
return super().get_form(*args, **kwargs)
31+
32+
def get_urls(self):
33+
urls = super().get_urls()
34+
prefix = self.model._meta.db_table
35+
my_urls = [
36+
path(
37+
"<int:pk>/preview-content/$",
38+
self.admin_site.admin_view(self.preview_email_template),
39+
name=f"{prefix}_preview",
40+
),
41+
]
42+
return my_urls + urls
43+
44+
def preview_email_template(self, request, pk, *args, **kwargs):
45+
qs = self.get_queryset(request)
46+
template = get_object_or_404(qs, pk=pk)
47+
return HttpResponse(template.render_content({}))

mailing/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.apps import AppConfig
2+
3+
4+
class MailingConfig(AppConfig):
5+
name = 'mailing'

mailing/forms.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from django import forms
2+
from django.template import Template, Context, TemplateSyntaxError
3+
4+
from mailing.models import BaseEmailTemplate
5+
6+
7+
class BaseEmailTemplateForm(forms.ModelForm):
8+
9+
def clean_content(self):
10+
content = self.cleaned_data["content"]
11+
try:
12+
template = Template(content)
13+
template.render(Context({}))
14+
return content
15+
except TemplateSyntaxError as e:
16+
raise forms.ValidationError(e)
17+
18+
class Meta:
19+
model = BaseEmailTemplate
20+
fields = "__all__"

mailing/migrations/__init__.py

Whitespace-only changes.

mailing/models.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from django.core.mail import EmailMessage
2+
from django.db import models
3+
from django.template import Template, Context
4+
from django.urls import reverse
5+
6+
7+
class BaseEmailTemplate(models.Model):
8+
internal_name = models.CharField(max_length=128)
9+
10+
subject = models.CharField(max_length=128)
11+
content = models.TextField()
12+
13+
created_at = models.DateTimeField(auto_now_add=True)
14+
updated_at = models.DateTimeField(auto_now=True)
15+
16+
@property
17+
def preview_content_url(self):
18+
prefix = self._meta.db_table
19+
url_name = f"admin:{prefix}_preview"
20+
return reverse(url_name, args=[self.pk])
21+
22+
def render_content(self, context):
23+
template = Template(self.content)
24+
ctx = Context(context)
25+
return template.render(ctx)
26+
27+
def render_subject(self, context):
28+
template = Template(self.subject)
29+
ctx = Context(context)
30+
return template.render(ctx)
31+
32+
def get_email(self, from_email, to, context=None, **kwargs):
33+
context = context or {}
34+
context = self.get_email_context_data(**context)
35+
subject = self.render_subject(context)
36+
content = self.render_content(context)
37+
return EmailMessage(subject, content, from_email, to, **kwargs)
38+
39+
def get_email_context_data(self, **kwargs):
40+
return kwargs
41+
42+
class Meta:
43+
abstract = True
44+
45+
def __str__(self):
46+
return f"Email template: {self.internal_name}"

mailing/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Create your tests here

mailing/tests/test_forms.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from django.test import TestCase
2+
3+
from mailing.forms import BaseEmailTemplateForm
4+
5+
6+
class BaseEmailTemplateFormTests(TestCase):
7+
8+
def setUp(self):
9+
self.data = {
10+
"content": "Hi, {{ name }}\n\nThis is a message to you.",
11+
"subject": "Hello",
12+
"internal_name": "notification 01",
13+
}
14+
15+
def test_validate_required_fields(self):
16+
required = set(self.data)
17+
form = BaseEmailTemplateForm(data={})
18+
self.assertFalse(form.is_valid())
19+
self.assertEqual(required, set(form.errors))
20+
21+
def test_validate_with_correct_data(self):
22+
form = BaseEmailTemplateForm(data=self.data)
23+
self.assertTrue(form.is_valid())
24+
25+
def test_invalid_form_if_broken_template_syntax(self):
26+
self.data["content"] = "Invalid syntax {% invalid %}"
27+
form = BaseEmailTemplateForm(data=self.data)
28+
self.assertFalse(form.is_valid())
29+
self.assertIn("content", form.errors, form.errors)

pydotorg/settings/base.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -167,24 +167,25 @@
167167
'easy_pdf',
168168
'sorl.thumbnail',
169169

170-
'users',
170+
'banners',
171+
'blogs',
171172
'boxes',
172173
'cms',
173-
'companies',
174+
'codesamples',
174175
'community',
176+
'companies',
177+
'downloads',
178+
'events',
175179
'jobs',
180+
'mailing',
181+
'minutes',
182+
'nominations',
176183
'pages',
184+
'peps',
177185
'sponsors',
178186
'successstories',
179-
'events',
180-
'minutes',
181-
'peps',
182-
'blogs',
183-
'downloads',
184-
'codesamples',
187+
'users',
185188
'work_groups',
186-
'nominations',
187-
'banners',
188189

189190
'allauth',
190191
'allauth.account',

sponsors/admin.py

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from ordered_model.admin import OrderedModelAdmin
22
from polymorphic.admin import PolymorphicInlineSupportMixin, StackedPolymorphicInline
33

4+
from django.db.models import Subquery
45
from django.template import Context, Template
56
from django.contrib import admin
67
from django.contrib.humanize.templatetags.humanize import intcomma
78
from django.urls import path, reverse
9+
from django.utils.functional import cached_property
810
from django.utils.html import mark_safe
911

12+
from mailing.admin import BaseEmailTemplateAdmin
1013
from .models import (
1114
SponsorshipPackage,
1215
SponsorshipProgram,
@@ -17,9 +20,12 @@
1720
SponsorBenefit,
1821
LegalClause,
1922
Contract,
23+
BenefitFeature,
2024
BenefitFeatureConfiguration,
2125
LogoPlacementConfiguration,
2226
TieredQuantityConfiguration,
27+
EmailTargetableConfiguration,
28+
SponsorEmailNotificationTemplate,
2329
)
2430
from sponsors import views_admin
2531
from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm
@@ -42,10 +48,18 @@ class LogoPlacementConfigurationInline(StackedPolymorphicInline.Child):
4248
class TieredQuantityConfigurationInline(StackedPolymorphicInline.Child):
4349
model = TieredQuantityConfiguration
4450

51+
class EmailTargetableConfigurationInline(StackedPolymorphicInline.Child):
52+
model = EmailTargetableConfiguration
53+
readonly_fields = ["display"]
54+
55+
def display(self, obj):
56+
return "Enabled"
57+
4558
model = BenefitFeatureConfiguration
4659
child_inlines = [
4760
LogoPlacementConfigurationInline,
4861
TieredQuantityConfigurationInline,
62+
EmailTargetableConfigurationInline,
4963
]
5064

5165

@@ -159,6 +173,31 @@ def has_delete_permission(self, request, obj=None):
159173
return obj.open_for_editing
160174

161175

176+
class TargetableEmailBenefitsFilter(admin.SimpleListFilter):
177+
title = "targetable email benefits"
178+
parameter_name = 'email_benefit'
179+
180+
@cached_property
181+
def benefits(self):
182+
qs = EmailTargetableConfiguration.objects.all().values_list("benefit_id", flat=True)
183+
benefits = SponsorshipBenefit.objects.filter(id__in=Subquery(qs))
184+
return {str(b.id): b for b in benefits}
185+
186+
def lookups(self, request, model_admin):
187+
return [
188+
(k, b.name) for k, b in self.benefits.items()
189+
]
190+
191+
def queryset(self, request, queryset):
192+
benefit = self.benefits.get(self.value())
193+
if not benefit:
194+
return queryset
195+
# all sponsors benefit related with such sponsorship benefit
196+
qs = SponsorBenefit.objects.filter(
197+
sponsorship_benefit_id=benefit.id).values_list("sponsorship_id", flat=True)
198+
return queryset.filter(id__in=Subquery(qs))
199+
200+
162201
@admin.register(Sponsorship)
163202
class SponsorshipAdmin(admin.ModelAdmin):
164203
change_form_template = "sponsors/admin/sponsorship_change_form.html"
@@ -174,8 +213,8 @@ class SponsorshipAdmin(admin.ModelAdmin):
174213
"start_date",
175214
"end_date",
176215
]
177-
list_filter = ["status", "package"]
178-
216+
list_filter = ["status", "package", TargetableEmailBenefitsFilter]
217+
actions = ["send_notifications"]
179218
fieldsets = [
180219
(
181220
"Sponsorship Data",
@@ -223,6 +262,14 @@ class SponsorshipAdmin(admin.ModelAdmin):
223262
),
224263
]
225264

265+
def get_queryset(self, *args, **kwargs):
266+
qs = super().get_queryset(*args, **kwargs)
267+
return qs.select_related("sponsor", "package")
268+
269+
def send_notifications(self, request, queryset):
270+
return views_admin.send_sponsorship_notifications_action(self, request, queryset)
271+
send_notifications.short_description = 'Send notifications to selected'
272+
226273
def get_readonly_fields(self, request, obj):
227274
readonly_fields = [
228275
"for_modified_package",
@@ -251,10 +298,6 @@ def get_readonly_fields(self, request, obj):
251298

252299
return readonly_fields
253300

254-
def get_queryset(self, *args, **kwargs):
255-
qs = super().get_queryset(*args, **kwargs)
256-
return qs.select_related("sponsor")
257-
258301
def get_estimated_cost(self, obj):
259302
cost = None
260303
html = "This sponsorship has not customizations so there's no estimated cost"
@@ -303,17 +346,14 @@ def get_urls(self):
303346

304347
def get_sponsor_name(self, obj):
305348
return obj.sponsor.name
306-
307349
get_sponsor_name.short_description = "Name"
308350

309351
def get_sponsor_description(self, obj):
310352
return obj.sponsor.description
311-
312353
get_sponsor_description.short_description = "Description"
313354

314355
def get_sponsor_landing_page_url(self, obj):
315356
return obj.sponsor.landing_page_url
316-
317357
get_sponsor_landing_page_url.short_description = "Landing Page URL"
318358

319359
def get_sponsor_web_logo(self, obj):
@@ -322,7 +362,6 @@ def get_sponsor_web_logo(self, obj):
322362
context = Context({'sponsor': obj.sponsor})
323363
html = template.render(context)
324364
return mark_safe(html)
325-
326365
get_sponsor_web_logo.short_description = "Web Logo"
327366

328367
def get_sponsor_print_logo(self, obj):
@@ -334,12 +373,10 @@ def get_sponsor_print_logo(self, obj):
334373
context = Context({'img': img})
335374
html = template.render(context)
336375
return mark_safe(html) if html else "---"
337-
338376
get_sponsor_print_logo.short_description = "Print Logo"
339377

340378
def get_sponsor_primary_phone(self, obj):
341379
return obj.sponsor.primary_phone
342-
343380
get_sponsor_primary_phone.short_description = "Primary Phone"
344381

345382
def get_sponsor_mailing_address(self, obj):
@@ -358,7 +395,6 @@ def get_sponsor_mailing_address(self, obj):
358395
html += f"<p>{mail_row}</p>"
359396
html += f"<p>{sponsor.postal_code}</p>"
360397
return mark_safe(html)
361-
362398
get_sponsor_mailing_address.short_description = "Mailing/Billing Address"
363399

364400
def get_sponsor_contacts(self, obj):
@@ -379,7 +415,6 @@ def get_sponsor_contacts(self, obj):
379415
)
380416
html += "</ul>"
381417
return mark_safe(html)
382-
383418
get_sponsor_contacts.short_description = "Contacts"
384419

385420
def rollback_to_editing_view(self, request, pk):
@@ -551,3 +586,8 @@ def execute_contract_view(self, request, pk):
551586

552587
def nullify_contract_view(self, request, pk):
553588
return views_admin.nullify_contract_view(self, request, pk)
589+
590+
591+
@admin.register(SponsorEmailNotificationTemplate)
592+
class SponsorEmailNotificationTemplateAdmin(BaseEmailTemplateAdmin):
593+
pass

0 commit comments

Comments
 (0)