[paperwork] Add admin action to send notification emails for new certifications

This commit is contained in:
Adam Goldsmith 2022-02-14 11:05:35 -05:00
parent 61fc2386e5
commit 1f5ffcc5d3
7 changed files with 313 additions and 2 deletions

View File

@ -1,4 +1,6 @@
from django.contrib import admin
from django.core import mail
from django.contrib import admin, messages
from django.db.models.functions import Now
from .models import (
CmsRedRiverVeteransScholarship,
@ -9,6 +11,7 @@ from .models import (
SpecialProgram,
Waiver,
)
from .certification_emails import all_certification_emails
class CertificationVersionInline(admin.TabularInline):
@ -79,6 +82,35 @@ class CertificationAdmin(admin.ModelAdmin):
("shop_lead_notified", admin.EmptyFieldListFilter),
]
actions = ["send_notifications"]
@admin.action(
description="Notify Shop Leads and Members of selected certifications"
)
def send_notifications(self, request, queryset):
try:
emails = list(all_certification_emails(queryset))
print(emails)
with mail.get_connection() as conn:
conn.send_messages(emails)
for cert in queryset:
cert.update(shop_lead_notified=Now())
self.message_user(
request,
f"{len(emails)} notifications sent for {len(queryset)} certifications",
messages.SUCCESS,
)
except Exception as e:
self.message_user(
request,
f"Failed to send notifications! {e}",
messages.ERROR,
)
raise
@admin.register(InstructorOrVendor)
class InstructorOrVendorAdmin(admin.ModelAdmin):

View File

@ -0,0 +1,102 @@
from itertools import groupby
from django.core import mail
from django.core.mail.message import sanitize_address
from django.contrib.auth import get_user_model
from django.template import loader
from markdownify import markdownify
import mdformat
from membershipworks.models import Member
def make_multipart_email(subject, html_body, to):
plain_body = mdformat.text(markdownify(html_body), extensions={"tables"})
email = mail.EmailMultiAlternatives(
subject,
plain_body,
"Claremont MakerSpace Member Certification System <Certifications@ClaremontMakerSpace.org>",
to,
reply_to=["Claremont MakerSpace <Info@ClaremontMakerSpace.org>"],
)
email.attach_alternative(html_body, "text/html")
return email
def make_department_email(department, certifications):
template = loader.get_template("paperwork/department_certifications_email.dj.html")
shop_leads = Member.objects.filter(
flags__type="label", flags__name="Shop Lead: " + department
).values_list("first_name", "account_name", "email", named=True)
html_body = template.render(
{
"shop_lead_names": [shop_lead.first_name for shop_lead in shop_leads],
"department": department,
"certifications": certifications,
}
)
return make_multipart_email(
f"{len(certifications)} new CMS Certifications issued for {department}",
html_body,
to=[
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:
yield make_department_email(department, list(certifications))
def make_member_email(member, certifications):
template = loader.get_template("paperwork/member_certifications_email.dj.html")
html_body = template.render({"member": member, "certifications": certifications})
return make_multipart_email(
f"You have been issued {len(certifications)} new CMS Certifications",
html_body,
to=[sanitize_address((member.account_name, member.email), "ascii")],
)
def member_emails(ordered_queryset):
certifications_by_member = groupby(
ordered_queryset.filter(member__isnull=False), lambda c: c.member
)
for member, certifications in certifications_by_member:
yield make_member_email(member, list(certifications))
def admin_email(ordered_queryset):
template = loader.get_template("paperwork/admin_certifications_email.dj.html")
html_body = template.render({"certifications": ordered_queryset})
return make_multipart_email(
f"{len(ordered_queryset)} new CMS Certifications issued",
html_body,
# TODO: Admin emails should probably be from a group, not all staff
to=[get_user_model().filter(is_staff=True).values("email", flat=True)],
)
def all_certification_emails(queryset):
ordered_queryset = queryset.select_related(
"certification_version__definition"
).order_by("certification_version__definition__department")
yield from department_emails(ordered_queryset)
yield from member_emails(ordered_queryset)
yield admin_email(ordered_queryset)

View File

@ -0,0 +1,22 @@
<p>The following Claremont MakerSpace Member Certifications have been issued:</p>
<table border="1">
<tr>
<th>Certification</th>
<th>Version</th>
<th>Member</th>
<th>Department</th>
<th>Certified By</th>
<th>Date</th>
</tr>
{% for certification in certifications %}
<tr>
<td>{{ certification.certification_version.definition.certification_name }}</td>
<td>{{ certification.certification_version.version }}</td>
<td>{{ certification.member }}</td>
<td>{{ certification.certification_version.definition.department }}</td>
<td>{{ certification.certified_by }}</td>
<td>{{ certification.date }}</td>
</tr>
{% endfor %}
</table>

View File

@ -0,0 +1,22 @@
<p>Dear <b>{{ shop_lead_names|join:", " }}</b>:</p>
<p>The following Claremont MakerSpace Member Certifications have been issued for the <b>{{ department }}</b>:</p>
<table border="1">
<tr>
<th>Certification</th>
<th>Version</th>
<th>Member</th>
<th>Certified By</th>
<th>Date</th>
</tr>
{% for certification in certifications %}
<tr>
<td>{{ certification.certification_version.definition.certification_name }}</td>
<td>{{ certification.certification_version.version }}</td>
<td>{{ certification.member }}</td>
<td>{{ certification.certified_by }}</td>
<td>{{ certification.date }}</td>
</tr>
{% endfor %}
</table>

View File

@ -0,0 +1,22 @@
<p>Dear <b>{{ member.first_name }}</b>:</p>
<p>You have been issued the following Claremont MakerSpace Member Certifications:</p>
<table border="1">
<tr>
<th>Certification</th>
<th>Version</th>
<th>Department</th>
<th>Certified By</th>
<th>Date</th>
</tr>
{% for certification in certifications %}
<tr>
<td>{{ certification.certification_version.definition.certification_name }}</td>
<td>{{ certification.certification_version.version }}</td>
<td>{{ certification.certification_version.definition.department }}</td>
<td>{{ certification.certified_by }}</td>
<td>{{ certification.date }}</td>
</tr>
{% endfor %}
</table>

110
pdm.lock
View File

@ -4,6 +4,21 @@ version = "3.5.0"
requires_python = ">=3.7"
summary = "ASGI specs, helper code, and adapters"
[[package]]
name = "attrs"
version = "21.4.0"
requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
summary = "Classes Without Boilerplate"
[[package]]
name = "beautifulsoup4"
version = "4.10.0"
requires_python = ">3.0.0"
summary = "Screen-scraping library"
dependencies = [
"soupsieve>1.2",
]
[[package]]
name = "black"
version = "22.1.0"
@ -88,6 +103,51 @@ dependencies = [
"zipp>=0.5",
]
[[package]]
name = "markdown-it-py"
version = "2.0.1"
requires_python = "~=3.6"
summary = "Python port of markdown-it. Markdown parsing, done right!"
dependencies = [
"attrs<22,>=19",
"mdurl~=0.1",
]
[[package]]
name = "markdownify"
version = "0.10.3"
summary = "Convert HTML to markdown."
dependencies = [
"beautifulsoup4<5,>=4.9",
"six<2,>=1.15",
]
[[package]]
name = "mdformat"
version = "0.7.13"
requires_python = ">=3.7,<4.0"
summary = "CommonMark compliant Markdown formatter"
dependencies = [
"importlib-metadata>=3.6.0; python_version < \"3.10\"",
"markdown-it-py<3.0.0,>=1.0.0b2",
"tomli>=1.1.0",
]
[[package]]
name = "mdformat-tables"
version = "0.4.1"
requires_python = ">=3.6.1"
summary = "An mdformat plugin for rendering tables."
dependencies = [
"mdformat<0.8.0,>=0.7.5",
]
[[package]]
name = "mdurl"
version = "0.1.0"
requires_python = ">=3.6"
summary = "Markdown URL utilities"
[[package]]
name = "mypy-extensions"
version = "0.4.3"
@ -144,6 +204,18 @@ name = "regex"
version = "2022.1.18"
summary = "Alternative regular expression module, to replace re."
[[package]]
name = "six"
version = "1.16.0"
requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
summary = "Python 2 and 3 compatibility utilities"
[[package]]
name = "soupsieve"
version = "2.3.1"
requires_python = ">=3.6"
summary = "A modern CSS selector implementation for Beautiful Soup."
[[package]]
name = "sqlparse"
version = "0.4.2"
@ -191,13 +263,21 @@ summary = "Backport of pathlib-compatible object wrapper for zip files"
[metadata]
lock_version = "3.1"
content_hash = "sha256:55cc2d62a3ec2d70115485f20fced290264220ac8d7522db79bfd76aecb42892"
content_hash = "sha256:2852221dbf368756c0afb4737dcabf25dac20ed97d9f8e2c1d27676e8e42645e"
[metadata.files]
"asgiref 3.5.0" = [
{file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"},
{file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"},
]
"attrs 21.4.0" = [
{file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
{file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
]
"beautifulsoup4 4.10.0" = [
{file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"},
{file = "beautifulsoup4-4.10.0.tar.gz", hash = "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"},
]
"black 22.1.0" = [
{file = "black-22.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1297c63b9e1b96a3d0da2d85d11cd9bf8664251fd69ddac068b98dc4f34f73b6"},
{file = "black-22.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2ff96450d3ad9ea499fc4c60e425a1439c2120cbbc1ab959ff20f7c76ec7e866"},
@ -251,6 +331,26 @@ content_hash = "sha256:55cc2d62a3ec2d70115485f20fced290264220ac8d7522db79bfd76ae
{file = "importlib_metadata-4.11.0-py3-none-any.whl", hash = "sha256:6affcdb3aec542dd98df8211e730bba6c5f2bec8288d47bacacde898f548c9ad"},
{file = "importlib_metadata-4.11.0.tar.gz", hash = "sha256:9e5e553bbba1843cb4a00823014b907616be46ee503d2b9ba001d214a8da218f"},
]
"markdown-it-py 2.0.1" = [
{file = "markdown_it_py-2.0.1-py3-none-any.whl", hash = "sha256:31974138ca8cafbcb62213f4974b29571b940e78364584729233f59b8dfdb8bd"},
{file = "markdown-it-py-2.0.1.tar.gz", hash = "sha256:7b5c153ae1ab2cde00a33938bce68f3ad5d68fbe363f946de7d28555bed4e08a"},
]
"markdownify 0.10.3" = [
{file = "markdownify-0.10.3-py3-none-any.whl", hash = "sha256:edad0ad3896ec7460d05537ad804bbb3614877c6cd0df27b56dee218236d9ce2"},
{file = "markdownify-0.10.3.tar.gz", hash = "sha256:782e310390cd5e4bde7543ceb644598c78b9824ee9f8d7ef9f9f4f8782e46974"},
]
"mdformat 0.7.13" = [
{file = "mdformat-0.7.13-py3-none-any.whl", hash = "sha256:accca5fb17da270b63d27ce05c86eba1e8dd2a0342e9c7bb4cff4e50040b65bb"},
{file = "mdformat-0.7.13.tar.gz", hash = "sha256:28ede5e435ba84154e332edced33ee24fa30453d8fcbfbf7e41cd126a0851112"},
]
"mdformat-tables 0.4.1" = [
{file = "mdformat_tables-0.4.1-py3-none-any.whl", hash = "sha256:981f3dc7350027f78e3fd6a5fe8a16e123eec423af2d140e588d855751501019"},
{file = "mdformat_tables-0.4.1.tar.gz", hash = "sha256:3024e88e9d29d7b8bb07fd6b59c9d5dcf14d2060122be29e30e72d27b65d7da9"},
]
"mdurl 0.1.0" = [
{file = "mdurl-0.1.0-py3-none-any.whl", hash = "sha256:40654d6dcb8d21501ed13c21cc0bd6fc42ff07ceb8be30029e5ae63ebc2ecfda"},
{file = "mdurl-0.1.0.tar.gz", hash = "sha256:94873a969008ee48880fb21bad7de0349fef529f3be178969af5817239e9b990"},
]
"mypy-extensions 0.4.3" = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
@ -392,6 +492,14 @@ content_hash = "sha256:55cc2d62a3ec2d70115485f20fced290264220ac8d7522db79bfd76ae
{file = "regex-2022.1.18-cp39-cp39-win_amd64.whl", hash = "sha256:ebaeb93f90c0903233b11ce913a7cb8f6ee069158406e056f884854c737d2442"},
{file = "regex-2022.1.18.tar.gz", hash = "sha256:97f32dc03a8054a4c4a5ab5d761ed4861e828b2c200febd4e46857069a483916"},
]
"six 1.16.0" = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
"soupsieve 2.3.1" = [
{file = "soupsieve-2.3.1-py3-none-any.whl", hash = "sha256:1a3cca2617c6b38c0343ed661b1fa5de5637f257d4fe22bd9f1338010a1efefb"},
{file = "soupsieve-2.3.1.tar.gz", hash = "sha256:b8d49b1cd4f037c7082a9683dfa1801aa2597fb11c3a1155b7a5b94829b4f1f9"},
]
"sqlparse 0.4.2" = [
{file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"},
{file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"},

View File

@ -10,6 +10,9 @@ dependencies = [
"mysqlclient~=2.1",
"django-auth-ldap~=4.0",
"django-admin-logs~=1.0",
"markdownify~=0.10",
"mdformat~=0.7",
"mdformat-tables~=0.4",
]
requires-python = ">=3.9"