Skip to content

Commit

Permalink
Sponsorships notifications (#1869)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
berinhard authored Oct 7, 2021
1 parent 9dc7cbe commit f99aa6d
Show file tree
Hide file tree
Showing 27 changed files with 934 additions and 36 deletions.
Empty file added mailing/__init__.py
Empty file.
47 changes: 47 additions & 0 deletions mailing/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from django.contrib import admin
from django.forms.models import modelform_factory
from django.http import HttpResponse
from django.urls import path
from django.shortcuts import get_object_or_404

from mailing.forms import BaseEmailTemplateForm


class BaseEmailTemplateAdmin(admin.ModelAdmin):
change_form_template = "mailing/admin/base_email_template_form.html"
list_display = ["internal_name", "subject"]
readonly_fields = ["created_at", "updated_at"]
search_fields = ["internal_name"]
fieldsets = (
(None, {
'fields': ('internal_name',)
}),
('Email template', {
'fields': ('subject', 'content')
}),
('Timestamps', {
'classes': ('collapse',),
'fields': ('created_at', 'updated_at'),
}),
)

def get_form(self, *args, **kwargs):
kwargs["form"] = modelform_factory(self.model, form=BaseEmailTemplateForm)
return super().get_form(*args, **kwargs)

def get_urls(self):
urls = super().get_urls()
prefix = self.model._meta.db_table
my_urls = [
path(
"<int:pk>/preview-content/$",
self.admin_site.admin_view(self.preview_email_template),
name=f"{prefix}_preview",
),
]
return my_urls + urls

def preview_email_template(self, request, pk, *args, **kwargs):
qs = self.get_queryset(request)
template = get_object_or_404(qs, pk=pk)
return HttpResponse(template.render_content({}))
5 changes: 5 additions & 0 deletions mailing/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class MailingConfig(AppConfig):
name = 'mailing'
20 changes: 20 additions & 0 deletions mailing/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from django import forms
from django.template import Template, Context, TemplateSyntaxError

from mailing.models import BaseEmailTemplate


class BaseEmailTemplateForm(forms.ModelForm):

def clean_content(self):
content = self.cleaned_data["content"]
try:
template = Template(content)
template.render(Context({}))
return content
except TemplateSyntaxError as e:
raise forms.ValidationError(e)

class Meta:
model = BaseEmailTemplate
fields = "__all__"
Empty file added mailing/migrations/__init__.py
Empty file.
46 changes: 46 additions & 0 deletions mailing/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from django.core.mail import EmailMessage
from django.db import models
from django.template import Template, Context
from django.urls import reverse


class BaseEmailTemplate(models.Model):
internal_name = models.CharField(max_length=128)

subject = models.CharField(max_length=128)
content = models.TextField()

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

@property
def preview_content_url(self):
prefix = self._meta.db_table
url_name = f"admin:{prefix}_preview"
return reverse(url_name, args=[self.pk])

def render_content(self, context):
template = Template(self.content)
ctx = Context(context)
return template.render(ctx)

def render_subject(self, context):
template = Template(self.subject)
ctx = Context(context)
return template.render(ctx)

def get_email(self, from_email, to, context=None, **kwargs):
context = context or {}
context = self.get_email_context_data(**context)
subject = self.render_subject(context)
content = self.render_content(context)
return EmailMessage(subject, content, from_email, to, **kwargs)

def get_email_context_data(self, **kwargs):
return kwargs

class Meta:
abstract = True

def __str__(self):
return f"Email template: {self.internal_name}"
1 change: 1 addition & 0 deletions mailing/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Create your tests here
29 changes: 29 additions & 0 deletions mailing/tests/test_forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from django.test import TestCase

from mailing.forms import BaseEmailTemplateForm


class BaseEmailTemplateFormTests(TestCase):

def setUp(self):
self.data = {
"content": "Hi, {{ name }}\n\nThis is a message to you.",
"subject": "Hello",
"internal_name": "notification 01",
}

def test_validate_required_fields(self):
required = set(self.data)
form = BaseEmailTemplateForm(data={})
self.assertFalse(form.is_valid())
self.assertEqual(required, set(form.errors))

def test_validate_with_correct_data(self):
form = BaseEmailTemplateForm(data=self.data)
self.assertTrue(form.is_valid())

def test_invalid_form_if_broken_template_syntax(self):
self.data["content"] = "Invalid syntax {% invalid %}"
form = BaseEmailTemplateForm(data=self.data)
self.assertFalse(form.is_valid())
self.assertIn("content", form.errors, form.errors)
21 changes: 11 additions & 10 deletions pydotorg/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,24 +167,25 @@
'easy_pdf',
'sorl.thumbnail',

'users',
'banners',
'blogs',
'boxes',
'cms',
'companies',
'codesamples',
'community',
'companies',
'downloads',
'events',
'jobs',
'mailing',
'minutes',
'nominations',
'pages',
'peps',
'sponsors',
'successstories',
'events',
'minutes',
'peps',
'blogs',
'downloads',
'codesamples',
'users',
'work_groups',
'nominations',
'banners',

'allauth',
'allauth.account',
Expand Down
68 changes: 54 additions & 14 deletions sponsors/admin.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from ordered_model.admin import OrderedModelAdmin
from polymorphic.admin import PolymorphicInlineSupportMixin, StackedPolymorphicInline

from django.db.models import Subquery
from django.template import Context, Template
from django.contrib import admin
from django.contrib.humanize.templatetags.humanize import intcomma
from django.urls import path, reverse
from django.utils.functional import cached_property
from django.utils.html import mark_safe

from mailing.admin import BaseEmailTemplateAdmin
from .models import (
SponsorshipPackage,
SponsorshipProgram,
Expand All @@ -17,9 +20,12 @@
SponsorBenefit,
LegalClause,
Contract,
BenefitFeature,
BenefitFeatureConfiguration,
LogoPlacementConfiguration,
TieredQuantityConfiguration,
EmailTargetableConfiguration,
SponsorEmailNotificationTemplate,
)
from sponsors import views_admin
from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm
Expand All @@ -42,10 +48,18 @@ class LogoPlacementConfigurationInline(StackedPolymorphicInline.Child):
class TieredQuantityConfigurationInline(StackedPolymorphicInline.Child):
model = TieredQuantityConfiguration

class EmailTargetableConfigurationInline(StackedPolymorphicInline.Child):
model = EmailTargetableConfiguration
readonly_fields = ["display"]

def display(self, obj):
return "Enabled"

model = BenefitFeatureConfiguration
child_inlines = [
LogoPlacementConfigurationInline,
TieredQuantityConfigurationInline,
EmailTargetableConfigurationInline,
]


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


class TargetableEmailBenefitsFilter(admin.SimpleListFilter):
title = "targetable email benefits"
parameter_name = 'email_benefit'

@cached_property
def benefits(self):
qs = EmailTargetableConfiguration.objects.all().values_list("benefit_id", flat=True)
benefits = SponsorshipBenefit.objects.filter(id__in=Subquery(qs))
return {str(b.id): b for b in benefits}

def lookups(self, request, model_admin):
return [
(k, b.name) for k, b in self.benefits.items()
]

def queryset(self, request, queryset):
benefit = self.benefits.get(self.value())
if not benefit:
return queryset
# all sponsors benefit related with such sponsorship benefit
qs = SponsorBenefit.objects.filter(
sponsorship_benefit_id=benefit.id).values_list("sponsorship_id", flat=True)
return queryset.filter(id__in=Subquery(qs))


@admin.register(Sponsorship)
class SponsorshipAdmin(admin.ModelAdmin):
change_form_template = "sponsors/admin/sponsorship_change_form.html"
Expand All @@ -174,8 +213,8 @@ class SponsorshipAdmin(admin.ModelAdmin):
"start_date",
"end_date",
]
list_filter = ["status", "package"]

list_filter = ["status", "package", TargetableEmailBenefitsFilter]
actions = ["send_notifications"]
fieldsets = [
(
"Sponsorship Data",
Expand Down Expand Up @@ -223,6 +262,14 @@ class SponsorshipAdmin(admin.ModelAdmin):
),
]

def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
return qs.select_related("sponsor", "package")

def send_notifications(self, request, queryset):
return views_admin.send_sponsorship_notifications_action(self, request, queryset)
send_notifications.short_description = 'Send notifications to selected'

def get_readonly_fields(self, request, obj):
readonly_fields = [
"for_modified_package",
Expand Down Expand Up @@ -251,10 +298,6 @@ def get_readonly_fields(self, request, obj):

return readonly_fields

def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
return qs.select_related("sponsor")

def get_estimated_cost(self, obj):
cost = None
html = "This sponsorship has not customizations so there's no estimated cost"
Expand Down Expand Up @@ -303,17 +346,14 @@ def get_urls(self):

def get_sponsor_name(self, obj):
return obj.sponsor.name

get_sponsor_name.short_description = "Name"

def get_sponsor_description(self, obj):
return obj.sponsor.description

get_sponsor_description.short_description = "Description"

def get_sponsor_landing_page_url(self, obj):
return obj.sponsor.landing_page_url

get_sponsor_landing_page_url.short_description = "Landing Page URL"

def get_sponsor_web_logo(self, obj):
Expand All @@ -322,7 +362,6 @@ def get_sponsor_web_logo(self, obj):
context = Context({'sponsor': obj.sponsor})
html = template.render(context)
return mark_safe(html)

get_sponsor_web_logo.short_description = "Web Logo"

def get_sponsor_print_logo(self, obj):
Expand All @@ -334,12 +373,10 @@ def get_sponsor_print_logo(self, obj):
context = Context({'img': img})
html = template.render(context)
return mark_safe(html) if html else "---"

get_sponsor_print_logo.short_description = "Print Logo"

def get_sponsor_primary_phone(self, obj):
return obj.sponsor.primary_phone

get_sponsor_primary_phone.short_description = "Primary Phone"

def get_sponsor_mailing_address(self, obj):
Expand All @@ -358,7 +395,6 @@ def get_sponsor_mailing_address(self, obj):
html += f"<p>{mail_row}</p>"
html += f"<p>{sponsor.postal_code}</p>"
return mark_safe(html)

get_sponsor_mailing_address.short_description = "Mailing/Billing Address"

def get_sponsor_contacts(self, obj):
Expand All @@ -379,7 +415,6 @@ def get_sponsor_contacts(self, obj):
)
html += "</ul>"
return mark_safe(html)

get_sponsor_contacts.short_description = "Contacts"

def rollback_to_editing_view(self, request, pk):
Expand Down Expand Up @@ -551,3 +586,8 @@ def execute_contract_view(self, request, pk):

def nullify_contract_view(self, request, pk):
return views_admin.nullify_contract_view(self, request, pk)


@admin.register(SponsorEmailNotificationTemplate)
class SponsorEmailNotificationTemplateAdmin(BaseEmailTemplateAdmin):
pass
Loading

0 comments on commit f99aa6d

Please sign in to comment.