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,67 +1,62 @@
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:
if invoice.event.instructor is None or invoice.event.instructor.member is None:
raise ValueError("Event Instructor not defined or is not member")
template = loader.get_template(
"membershipworks/email/event_invoice_instructor.dj.html"
)
html_body = template.render({"invoice": invoice, "event_url": event_url})
message = make_multipart_email( @property
f'Your CMS instructor invoice has been received for event "{invoice.event}" {invoice.event.start} - {invoice.event.end}', def subject(self) -> str:
html_body, event = self.context["invoice"].event
(invoice.event.instructor.member.sanitized_mailbox(),), return f'Your CMS instructor invoice has been received for event "{event}" {event.start} - {event.end}'
)
message.attach(f"CMS_event_invoice_{invoice.uuid}.pdf", pdf, "application/pdf") @classmethod
return message 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:
raise ValueError("Event Instructor not defined or is not member")
message = cls.render(
{"invoice": invoice, "event_url": event_url},
to=[invoice.event.instructor.member.sanitized_mailbox()],
)
message.attach(f"CMS_event_invoice_{invoice.uuid}.pdf", pdf, "application/pdf")
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
# TODO: should this be in database instead? return f'CMS instructor invoice created for event "{event}" {event.start} - {event.end}'
settings.INVOICE_HANDLERS,
) @classmethod
message.attach(f"CMS_event_invoice_{invoice.uuid}.pdf", pdf, "application/pdf") def render_for_invoice(
return message 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?
to=settings.INVOICE_HANDLERS,
)
message.attach(f"CMS_event_invoice_{invoice.uuid}.pdf", pdf, "application/pdf")
return message
def make_invoice_emails( 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"
shop_leads = department.shop_lead_flag.members.values_list(
"first_name", "account_name", "email", named=True
)
html_body = template.render( @property
{ def subject(self) -> str:
"shop_lead_names": [shop_lead.first_name for shop_lead in shop_leads], certification_count = len(self.context["certifications"])
"department": department, return f"{certification_count} new CMS Certifications issued for {self.context['department']}"
"certifications": certifications,
}
)
return make_multipart_email( @classmethod
f"{len(certifications)} new CMS Certifications issued for {department}", def render_for_department(
html_body, cls, department: Department, certifications: Iterator[Certification]
to=[ ) -> Iterable[mail.EmailMessage]:
sanitize_address((shop_lead.account_name, shop_lead.email), "ascii")
for shop_lead in shop_leads
],
)
def department_emails(ordered_queryset):
certifications_by_department = groupby(
ordered_queryset, lambda c: c.certification_version.definition.department
)
for department, certifications in certifications_by_department:
if department.shop_lead_flag is not None: if department.shop_lead_flag is not None:
yield make_department_email(department, list(certifications)) shop_leads = department.shop_lead_flag.members.values_list(
"first_name", "account_name", "email", named=True
)
yield cls.render(
{
"shop_lead_names": [
shop_lead.first_name for shop_lead in shop_leads
],
"department": department,
"certifications": list(certifications),
},
to=[
sanitize_address((shop_lead.account_name, shop_lead.email), "ascii")
for shop_lead in shop_leads
],
)
@classmethod
def render_for_certifications(
cls, ordered_certifications: QuerySet[Certification]
) -> Iterable[mail.EmailMessage]:
certifications_by_department = groupby(
ordered_certifications,
lambda c: c.certification_version.definition.department,
)
for department, certifications in certifications_by_department:
yield from cls.render_for_department(department, 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]
to=[sanitize_address((member.account_name, member.email), "ascii")], ) -> mail.EmailMessage:
) return cls.render(
{"member": member, "certifications": certifications},
to=[sanitize_address((member.account_name, member.email), "ascii")],
)
@classmethod
def render_for_certifications(
cls, ordered_certifications: QuerySet[Certification]
) -> Iterable[mail.EmailMessage]:
certifications_by_member = groupby(
ordered_certifications.filter(member__isnull=False), lambda c: c.member
)
for member, certifications in certifications_by_member:
yield cls.render_for_member(member, list(certifications))
def member_emails(ordered_queryset): class AdminCertificationEmail(CertificationEmailBase):
certifications_by_member = groupby( template = "paperwork/email/admin_certifications.dj.html"
ordered_queryset.filter(member__isnull=False), lambda c: c.member
)
for member, certifications in certifications_by_member: @property
yield make_member_email(member, list(certifications)) def subject(self) -> str:
return f"{len(self.context['certifications'])} new CMS Certifications issued"
@classmethod
def admin_email(ordered_queryset): def render_for_certifications(
template = loader.get_template("paperwork/email/admin_certifications.dj.html") cls, ordered_certifications: QuerySet[Certification]
html_body = template.render({"certifications": ordered_queryset}) ) -> Iterable[mail.EmailMessage]:
yield cls.render(
return make_multipart_email( {"certifications": ordered_certifications},
f"{len(ordered_queryset)} new CMS Certifications issued", to=get_user_model()
html_body,
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)