[paperwork] Add admin action to send notification emails for new certifications
This commit is contained in:
parent
61fc2386e5
commit
1f5ffcc5d3
@ -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):
|
||||
|
102
paperwork/certification_emails.py
Normal file
102
paperwork/certification_emails.py
Normal 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)
|
@ -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>
|
@ -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>
|
@ -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
generated
110
pdm.lock
generated
@ -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"},
|
||||
|
@ -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"
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user