From 1f5ffcc5d3491522ba04f158c21e773c8e6b7d9f Mon Sep 17 00:00:00 2001 From: Adam Goldsmith Date: Mon, 14 Feb 2022 11:05:35 -0500 Subject: [PATCH] [paperwork] Add admin action to send notification emails for new certifications --- paperwork/admin.py | 34 +++++- paperwork/certification_emails.py | 102 ++++++++++++++++ .../admin_certifications_email.dj.html | 22 ++++ .../department_certifications_email.dj.html | 22 ++++ .../member_certifications_email.dj.html | 22 ++++ pdm.lock | 110 +++++++++++++++++- pyproject.toml | 3 + 7 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 paperwork/certification_emails.py create mode 100644 paperwork/templates/paperwork/admin_certifications_email.dj.html create mode 100644 paperwork/templates/paperwork/department_certifications_email.dj.html create mode 100644 paperwork/templates/paperwork/member_certifications_email.dj.html diff --git a/paperwork/admin.py b/paperwork/admin.py index 6fe9f24..1849dff 100644 --- a/paperwork/admin.py +++ b/paperwork/admin.py @@ -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): diff --git a/paperwork/certification_emails.py b/paperwork/certification_emails.py new file mode 100644 index 0000000..e6374f5 --- /dev/null +++ b/paperwork/certification_emails.py @@ -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 ", + to, + reply_to=["Claremont MakerSpace "], + ) + + 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) diff --git a/paperwork/templates/paperwork/admin_certifications_email.dj.html b/paperwork/templates/paperwork/admin_certifications_email.dj.html new file mode 100644 index 0000000..5dac06d --- /dev/null +++ b/paperwork/templates/paperwork/admin_certifications_email.dj.html @@ -0,0 +1,22 @@ +

The following Claremont MakerSpace Member Certifications have been issued:

+ + + + + + + + + + + {% for certification in certifications %} + + + + + + + + + {% endfor %} +
CertificationVersionMemberDepartmentCertified ByDate
{{ certification.certification_version.definition.certification_name }}{{ certification.certification_version.version }}{{ certification.member }}{{ certification.certification_version.definition.department }}{{ certification.certified_by }}{{ certification.date }}
diff --git a/paperwork/templates/paperwork/department_certifications_email.dj.html b/paperwork/templates/paperwork/department_certifications_email.dj.html new file mode 100644 index 0000000..522d582 --- /dev/null +++ b/paperwork/templates/paperwork/department_certifications_email.dj.html @@ -0,0 +1,22 @@ +

Dear {{ shop_lead_names|join:", " }}:

+ +

The following Claremont MakerSpace Member Certifications have been issued for the {{ department }}:

+ + + + + + + + + + {% for certification in certifications %} + + + + + + + + {% endfor %} +
CertificationVersionMemberCertified ByDate
{{ certification.certification_version.definition.certification_name }}{{ certification.certification_version.version }}{{ certification.member }}{{ certification.certified_by }}{{ certification.date }}
diff --git a/paperwork/templates/paperwork/member_certifications_email.dj.html b/paperwork/templates/paperwork/member_certifications_email.dj.html new file mode 100644 index 0000000..79a090e --- /dev/null +++ b/paperwork/templates/paperwork/member_certifications_email.dj.html @@ -0,0 +1,22 @@ +

Dear {{ member.first_name }}:

+ +

You have been issued the following Claremont MakerSpace Member Certifications:

+ + + + + + + + + + {% for certification in certifications %} + + + + + + + + {% endfor %} +
CertificationVersionDepartmentCertified ByDate
{{ certification.certification_version.definition.certification_name }}{{ certification.certification_version.version }}{{ certification.certification_version.definition.department }}{{ certification.certified_by }}{{ certification.date }}
diff --git a/pdm.lock b/pdm.lock index 6e40151..cb74b6f 100644 --- a/pdm.lock +++ b/pdm.lock @@ -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"}, diff --git a/pyproject.toml b/pyproject.toml index bdf0d79..df251e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"