Refactor email generation to use a class-based design like Django views

This commit is contained in:
Adam Goldsmith 2024-05-16 00:58:17 -04:00
parent a7e7fafedd
commit 7ce28d449f
3 changed files with 223 additions and 124 deletions

81
cmsmanage/email.py Normal file
View File

@ -0,0 +1,81 @@
from collections.abc import Sequence
from typing import Any, ClassVar, TypeAlias
from django.core import mail
from django.template import loader
import mdformat
from markdownify import markdownify
Context: TypeAlias = dict[str, Any]
class EmailBase:
subject: ClassVar[str]
from_email: ClassVar[str | None] = None
reply_to: ClassVar[Sequence[str] | None] = None
context: Context
def __init__(self, context: Context) -> None:
self.context = context
def render_body(self) -> str:
raise NotImplementedError
@classmethod
def render(
cls,
context: Context,
to: list[str] | None = None,
cc: list[str] | None = None,
bcc: list[str] | None = None,
) -> mail.EmailMessage:
self = cls(context)
body = self.render_body()
return mail.EmailMessage(
self.subject,
body,
from_email=self.from_email,
reply_to=self.reply_to,
to=to,
cc=cc,
bcc=bcc,
)
class TemplatedMultipartEmail(EmailBase):
template: ClassVar[str]
def render_html_body(self) -> str:
template = loader.get_template(self.template)
return template.render(self.context)
def render_body(self, html_body: str) -> str:
return mdformat.text(markdownify(html_body), extensions={"tables"})
@classmethod
def render(
cls,
context: Context,
to: list[str] | None = None,
cc: list[str] | None = None,
bcc: list[str] | None = None,
) -> mail.EmailMessage:
self = cls(context)
html_body = self.render_html_body()
plain_body = self.render_body(html_body)
return mail.EmailMultiAlternatives(
self.subject,
plain_body,
from_email=self.from_email,
reply_to=self.reply_to,
to=to,
cc=cc,
bcc=bcc,
alternatives=[(html_body, "text/html")],
)

View File

@ -1,58 +1,53 @@
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMessage, EmailMultiAlternatives from django.core.mail import EmailMessage
from django.template import loader
import mdformat
from markdownify import markdownify
from cmsmanage.email import TemplatedMultipartEmail
from membershipworks.models import EventInvoice from membershipworks.models import EventInvoice
def make_multipart_email( class InvoiceEmailBase(TemplatedMultipartEmail):
subject: str, html_body: str, to: tuple[str] from_email = "CMS Invoices <invoices@claremontmakerspace.org>"
) -> EmailMultiAlternatives: reply_to = ["Claremont MakerSpace <Info@ClaremontMakerSpace.org>"]
plain_body = mdformat.text(markdownify(html_body), extensions={"tables"})
email = EmailMultiAlternatives(
subject,
plain_body,
from_email="CMS Invoices <invoices@claremontmakerspace.org>",
to=to,
reply_to=["Claremont MakerSpace <Info@ClaremontMakerSpace.org>"],
)
email.attach_alternative(html_body, "text/html")
return email
def make_instructor_email( class InstructorInvoiceEmail(InvoiceEmailBase):
invoice: EventInvoice, pdf: bytes, event_url: str template = "membershipworks/email/event_invoice_instructor.dj.html"
) -> EmailMessage:
@property
def subject(self) -> str:
event = self.context["invoice"].event
return f'Your CMS instructor invoice has been received for event "{event}" {event.start} - {event.end}'
@classmethod
def render_for_invoice(
cls, invoice: EventInvoice, pdf: bytes, event_url: str
) -> EmailMessage:
if invoice.event.instructor is None or invoice.event.instructor.member is None: if invoice.event.instructor is None or invoice.event.instructor.member is None:
raise ValueError("Event Instructor not defined or is not member") raise ValueError("Event Instructor not defined or is not member")
template = loader.get_template( message = cls.render(
"membershipworks/email/event_invoice_instructor.dj.html" {"invoice": invoice, "event_url": event_url},
) to=[invoice.event.instructor.member.sanitized_mailbox()],
html_body = template.render({"invoice": invoice, "event_url": event_url})
message = make_multipart_email(
f'Your CMS instructor invoice has been received for event "{invoice.event}" {invoice.event.start} - {invoice.event.end}',
html_body,
(invoice.event.instructor.member.sanitized_mailbox(),),
) )
message.attach(f"CMS_event_invoice_{invoice.uuid}.pdf", pdf, "application/pdf") message.attach(f"CMS_event_invoice_{invoice.uuid}.pdf", pdf, "application/pdf")
return message return message
def make_admin_email(invoice: EventInvoice, pdf: bytes, event_url: str) -> EmailMessage: class AdminInvoiceEmail(InvoiceEmailBase):
template = loader.get_template("membershipworks/email/event_invoice_admin.dj.html") template = "membershipworks/email/event_invoice_admin.dj.html"
html_body = template.render({"invoice": invoice, "event_url": event_url})
message = make_multipart_email( @property
f'CMS instructor invoice created for event "{invoice.event}" {invoice.event.start} - {invoice.event.end}', def subject(self) -> str:
html_body, event = self.context["invoice"].event
return f'CMS instructor invoice created for event "{event}" {event.start} - {event.end}'
@classmethod
def render_for_invoice(
cls, invoice: EventInvoice, pdf: bytes, event_url: str
) -> EmailMessage:
message = cls.render(
{"invoice": invoice, "event_url": event_url},
# TODO: should this be in database instead? # TODO: should this be in database instead?
settings.INVOICE_HANDLERS, to=settings.INVOICE_HANDLERS,
) )
message.attach(f"CMS_event_invoice_{invoice.uuid}.pdf", pdf, "application/pdf") message.attach(f"CMS_event_invoice_{invoice.uuid}.pdf", pdf, "application/pdf")
return message return message
@ -62,6 +57,6 @@ def make_invoice_emails(
invoice: EventInvoice, pdf: bytes, event_url: str invoice: EventInvoice, pdf: bytes, event_url: str
) -> list[EmailMessage]: ) -> list[EmailMessage]:
return [ return [
make_instructor_email(invoice, pdf, event_url), InstructorInvoiceEmail.render_for_invoice(invoice, pdf, event_url),
make_admin_email(invoice, pdf, event_url), AdminInvoiceEmail.render_for_invoice(invoice, pdf, event_url),
] ]

View File

@ -1,93 +1,114 @@
from abc import abstractmethod
from collections.abc import Iterable, Iterator
from itertools import groupby from itertools import groupby
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core import mail from django.core import mail
from django.core.mail.message import sanitize_address from django.core.mail.message import sanitize_address
from django.template import loader from django.db.models import QuerySet
import mdformat from cmsmanage.email import TemplatedMultipartEmail
from markdownify import markdownify from membershipworks.models import Member
from paperwork.models import Certification, Department
def make_multipart_email(subject, html_body, to): class CertificationEmailBase(TemplatedMultipartEmail):
plain_body = mdformat.text(markdownify(html_body), extensions={"tables"}) from_email = "Claremont MakerSpace Member Certification System <Certifications@ClaremontMakerSpace.org>"
reply_to = ["Claremont MakerSpace <Info@ClaremontMakerSpace.org>"]
email = mail.EmailMultiAlternatives( @classmethod
subject, @abstractmethod
plain_body, def render_for_certifications(
"Claremont MakerSpace Member Certification System <Certifications@ClaremontMakerSpace.org>", cls, ordered_certifications: QuerySet[Certification]
to, ) -> Iterable[mail.EmailMessage]:
reply_to=["Claremont MakerSpace <Info@ClaremontMakerSpace.org>"], raise NotImplementedError
)
email.attach_alternative(html_body, "text/html")
return email
def make_department_email(department, certifications): class DepartmentCertificationEmail(CertificationEmailBase):
template = loader.get_template("paperwork/email/department_certifications.dj.html") template = "paperwork/email/department_certifications.dj.html"
@property
def subject(self) -> str:
certification_count = len(self.context["certifications"])
return f"{certification_count} new CMS Certifications issued for {self.context['department']}"
@classmethod
def render_for_department(
cls, department: Department, certifications: Iterator[Certification]
) -> Iterable[mail.EmailMessage]:
if department.shop_lead_flag is not None:
shop_leads = department.shop_lead_flag.members.values_list( shop_leads = department.shop_lead_flag.members.values_list(
"first_name", "account_name", "email", named=True "first_name", "account_name", "email", named=True
) )
yield cls.render(
html_body = template.render(
{ {
"shop_lead_names": [shop_lead.first_name for shop_lead in shop_leads], "shop_lead_names": [
shop_lead.first_name for shop_lead in shop_leads
],
"department": department, "department": department,
"certifications": certifications, "certifications": list(certifications),
} },
)
return make_multipart_email(
f"{len(certifications)} new CMS Certifications issued for {department}",
html_body,
to=[ to=[
sanitize_address((shop_lead.account_name, shop_lead.email), "ascii") sanitize_address((shop_lead.account_name, shop_lead.email), "ascii")
for shop_lead in shop_leads for shop_lead in shop_leads
], ],
) )
@classmethod
def department_emails(ordered_queryset): def render_for_certifications(
cls, ordered_certifications: QuerySet[Certification]
) -> Iterable[mail.EmailMessage]:
certifications_by_department = groupby( certifications_by_department = groupby(
ordered_queryset, lambda c: c.certification_version.definition.department ordered_certifications,
lambda c: c.certification_version.definition.department,
) )
for department, certifications in certifications_by_department: for department, certifications in certifications_by_department:
if department.shop_lead_flag is not None: yield from cls.render_for_department(department, certifications)
yield make_department_email(department, list(certifications))
def make_member_email(member, certifications): class MemberCertificationEmail(CertificationEmailBase):
template = loader.get_template("paperwork/email/member_certifications.dj.html") template = "paperwork/email/member_certifications.dj.html"
html_body = template.render({"member": member, "certifications": certifications}) @property
def subject(self) -> str:
return f"You have been issued {len(self.context['certifications'])} new CMS Certifications"
return make_multipart_email( @classmethod
f"You have been issued {len(certifications)} new CMS Certifications", def render_for_member(
html_body, cls, member: Member, certifications: list[Certification]
) -> mail.EmailMessage:
return cls.render(
{"member": member, "certifications": certifications},
to=[sanitize_address((member.account_name, member.email), "ascii")], to=[sanitize_address((member.account_name, member.email), "ascii")],
) )
@classmethod
def member_emails(ordered_queryset): def render_for_certifications(
cls, ordered_certifications: QuerySet[Certification]
) -> Iterable[mail.EmailMessage]:
certifications_by_member = groupby( certifications_by_member = groupby(
ordered_queryset.filter(member__isnull=False), lambda c: c.member ordered_certifications.filter(member__isnull=False), lambda c: c.member
) )
for member, certifications in certifications_by_member: for member, certifications in certifications_by_member:
yield make_member_email(member, list(certifications)) yield cls.render_for_member(member, list(certifications))
def admin_email(ordered_queryset): class AdminCertificationEmail(CertificationEmailBase):
template = loader.get_template("paperwork/email/admin_certifications.dj.html") template = "paperwork/email/admin_certifications.dj.html"
html_body = template.render({"certifications": ordered_queryset})
return make_multipart_email( @property
f"{len(ordered_queryset)} new CMS Certifications issued", def subject(self) -> str:
html_body, return f"{len(self.context['certifications'])} new CMS Certifications issued"
to=(
get_user_model() @classmethod
def render_for_certifications(
cls, ordered_certifications: QuerySet[Certification]
) -> Iterable[mail.EmailMessage]:
yield cls.render(
{"certifications": ordered_certifications},
to=get_user_model()
.objects.with_perm( .objects.with_perm(
"paperwork.receive_certification_emails", "paperwork.receive_certification_emails",
include_superusers=False, include_superusers=False,
@ -95,16 +116,18 @@ def admin_email(ordered_queryset):
# TODO: LDAPBackend does not support with_perm() directly # TODO: LDAPBackend does not support with_perm() directly
backend="django.contrib.auth.backends.ModelBackend", backend="django.contrib.auth.backends.ModelBackend",
) )
.values_list("email", flat=True) .values_list("email", flat=True),
),
) )
def all_certification_emails(queryset): def all_certification_emails(queryset: QuerySet[Certification]):
ordered_queryset = queryset.select_related( ordered_queryset = queryset.select_related(
"certification_version__definition" "certification_version__definition"
).order_by("certification_version__definition__department") ).order_by("certification_version__definition__department")
yield from department_emails(ordered_queryset) for email_type in [
yield from member_emails(ordered_queryset) DepartmentCertificationEmail,
yield admin_email(ordered_queryset) MemberCertificationEmail,
AdminCertificationEmail,
]:
yield from email_type.render_for_certifications(ordered_queryset)