Skip to content

Commit

Permalink
Refactor the e-mail flow (#305)
Browse files Browse the repository at this point in the history
- remove EmailTemplate
- async the sending of e-mails
- create TXT & HTML templates
  • Loading branch information
tudoramariei authored Jul 5, 2024
1 parent 395480c commit e89b296
Show file tree
Hide file tree
Showing 25 changed files with 346 additions and 216 deletions.
Empty file.
74 changes: 74 additions & 0 deletions backend/civil_society_vote/common/messaging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import logging
from typing import Dict, List, Optional

from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template, render_to_string
from django.utils.translation import gettext_lazy as _
from django_q.tasks import async_task

logger = logging.getLogger(__name__)


def send_email(
subject: str,
to_emails: List[str],
text_template: str,
html_template: str,
context: Dict,
from_email: Optional[str] = None,
):
if settings.EMAIL_SEND_METHOD == "async":
async_send_email(subject, to_emails, text_template, html_template, context, from_email)
elif settings.EMAIL_SEND_METHOD == "sync":
send_emails(to_emails, subject, text_template, html_template, context, from_email)
else:
raise ValueError(_("Invalid email send method. Must be 'async' or 'sync'."))


def async_send_email(
subject: str,
to_emails: List[str],
text_template: str,
html_template: str,
html_context: Dict,
from_email: Optional[str] = None,
):
logger.info(f"Asynchronously sending {len(to_emails)} emails with subject: {subject}.")

async_task(
send_emails,
to_emails,
subject,
text_template,
html_template,
html_context,
from_email,
)


def send_emails(
user_emails: List[str],
subject: str,
text_template: str,
html_template: str,
html_context: Dict,
from_email: Optional[str] = None,
):
logger.info(f"Sending emails to {len(user_emails)} users.")

for email in user_emails:
text_body = render_to_string(text_template, context=html_context)

html = get_template(html_template)
html_content = html.render(html_context)

if not from_email:
from_email = (
settings.DEFAULT_FROM_EMAIL if hasattr(settings, "DEFAULT_FROM_EMAIL") else settings.NO_REPLY_EMAIL
)

msg = EmailMultiAlternatives(subject, text_body, from_email, [email])
msg.attach_alternative(html_content, "text/html")

msg.send(fail_silently=False)
1 change: 1 addition & 0 deletions backend/civil_society_vote/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@
"crispy_forms",
"django_crispy_bulma",
"storages",
"django_q",
"django_recaptcha",
"file_resubmit",
"rangefilter",
Expand Down
46 changes: 13 additions & 33 deletions backend/hub/admin.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
import csv
import io

from accounts.models import User
from django.contrib import admin, messages
from django.contrib.admin.filters import AllValuesFieldListFilter
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group
from django.contrib.sites.shortcuts import get_current_site
from django.shortcuts import redirect, render
from django.template import Context
from django.urls import path, reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from impersonate.admin import UserAdminImpersonateMixin

from hub import utils
from accounts.models import User
from civil_society_vote.common.messaging import send_email
from hub.forms import ImportCitiesForm
from hub.models import (
BlogPost,
COMMITTEE_GROUP,
COUNTIES,
COUNTY_RESIDENCE,
BlogPost,
Candidate,
CandidateConfirmation,
CandidateSupporter,
CandidateVote,
City,
Domain,
EmailTemplate,
FLAG_CHOICES,
FeatureFlag,
Organization,
Expand Down Expand Up @@ -78,7 +76,7 @@ def get_groups(self, obj=None):
get_groups.short_description = _("groups")


# NOTE: This is needed in order for impersonate to work
# NOTE: This is needed in order for impersonation to work
# admin.site.unregister(User)
admin.site.register(User, ImpersonableUserAdmin)

Expand Down Expand Up @@ -223,17 +221,16 @@ def send_confirm_email_to_committee(request, candidate, to_email):
confirmation_link_path = reverse("candidate-status-confirm", args=(candidate.pk,))
confirmation_link = f"{protocol}://{current_site.domain}{confirmation_link_path}"

utils.send_email(
template="confirmation",
context=Context(
{
"candidate": candidate.name,
"status": Candidate.STATUS[candidate.status],
"confirmation_link": confirmation_link,
}
),
send_email(
subject=f"[VOTONG] Confirmare candidatura: {candidate.name}",
to=to_email,
context={
"candidate": candidate.name,
"status": Candidate.STATUS[candidate.status],
"confirmation_link": confirmation_link,
},
to_emails=[to_email],
text_template="emails/05_confirmation.txt",
html_template="emails/05_confirmation.html",
)


Expand Down Expand Up @@ -563,23 +560,6 @@ def has_change_permission(self, request, obj=None):
return False


@admin.register(EmailTemplate)
class EmailTemplateAdmin(admin.ModelAdmin):
list_display = ["template"]
readonly_fields = ["template"]

def has_add_permission(self, request):
return False

def has_delete_permission(self, request, obj=None):
return False

def has_change_permission(self, request, obj=None):
if request.user.is_superuser:
return True
return False


@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
list_display = ["title", "slug", "author", "published_date", "is_visible"]
Expand Down
26 changes: 16 additions & 10 deletions backend/hub/forms.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
from django_recaptcha.fields import ReCaptchaField
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail
from django.urls import reverse_lazy
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_recaptcha.fields import ReCaptchaField

# from django_crispy_bulma.widgets import EmailInput

from civil_society_vote.common.messaging import send_email
from hub.models import Candidate, City, Domain, FeatureFlag, Organization

# from django_crispy_bulma.widgets import EmailInput

ORG_FIELD_ORDER = [
"name",
"county",
Expand Down Expand Up @@ -276,10 +276,16 @@ def __init__(self, *args, **kwargs):
if not settings.RECAPTCHA_PUBLIC_KEY:
del self.fields["captcha"]

def send_email(self):
send_mail(
f"Contact {self.cleaned_data.get('name')}",
f"{self.cleaned_data.get('email')}: {self.cleaned_data.get('message')}",
settings.NO_REPLY_EMAIL,
(settings.DEFAULT_FROM_EMAIL,),
def send_form_email(self):
send_email(
subject=f"[VotONG] Contact {self.cleaned_data.get('name')}",
to_emails=[settings.DEFAULT_FROM_EMAIL],
text_template="emails/06_contact.txt",
html_template="emails/06_contact.html",
context={
"name": self.cleaned_data.get("name"),
"email": self.cleaned_data.get("email"),
"message": self.cleaned_data.get("message"),
},
from_email=settings.NO_REPLY_EMAIL,
)
67 changes: 1 addition & 66 deletions backend/hub/management/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,7 @@
from django.core.management.base import BaseCommand
from guardian.shortcuts import assign_perm

from hub.models import COMMITTEE_GROUP, FLAG_CHOICES, NGO_GROUP, STAFF_GROUP, SUPPORT_GROUP, EmailTemplate, FeatureFlag

PENDING_ORGS_DIGEST_TEXT = """
Buna ziua,
Un numar de {{ nr_pending_orgs }} organizatii asteapta aprobarea voastra pe platforma VotONG.
Accesati adresa {{ committee_org_list_url }} pentru a vizualiza ultimele aplicatii.
Va multumim!
Echipa VotONG
"""

ORG_APPROVED_TEXT = """
Buna {{ representative }},
Cererea de inscriere a organizatiei "{{ name }}" in platforma VotONG a fost aprobata.
Pentru a va activa contul mergeti la adresa {{ reset_url }}, introduceti adresa de email folosita la inscriere si resetati parola contului.
Va multumim!
Echipa VotONG
"""

ORG_REJECTED_TEXT = """
{{ rejection_message }}
"""

VOTE_AUDIT_TEXT = """
{{ org }} a votat cu {{ candidate }} la {{ timestamp }}
Organizatie: {{ org_link }}
Candidat: {{ candidate_link }}
"""

CONFIRM_TEXT = """
Administratorul site-ului votong.ro a schimbatul statusul candidatului "{{ candidate }}" în "{{ status }}".
Urmează acest link pentru a confirma acțiunea: {{ confirmation_link }}
"""
from hub.models import COMMITTEE_GROUP, FLAG_CHOICES, FeatureFlag, NGO_GROUP, STAFF_GROUP, SUPPORT_GROUP


class Command(BaseCommand):
Expand All @@ -68,29 +28,4 @@ def handle(self, *args, **options):
for flag in [x[0] for x in FLAG_CHOICES]:
FeatureFlag.objects.get_or_create(flag=flag)

template, created = EmailTemplate.objects.get_or_create(template="pending_orgs_digest")
if created:
template.text_content = PENDING_ORGS_DIGEST_TEXT
template.save()

template, created = EmailTemplate.objects.get_or_create(template="org_approved")
if created:
template.text_content = ORG_APPROVED_TEXT
template.save()

template, created = EmailTemplate.objects.get_or_create(template="org_rejected")
if created:
template.text_content = ORG_REJECTED_TEXT
template.save()

template, created = EmailTemplate.objects.get_or_create(template="vote_audit")
if created:
template.text_content = VOTE_AUDIT_TEXT
template.save()

template, created = EmailTemplate.objects.get_or_create(template="confirmation")
if created:
template.text_content = CONFIRM_TEXT
template.save()

self.stdout.write(self.style.SUCCESS("Initialization done!"))
24 changes: 24 additions & 0 deletions backend/hub/migrations/0049_delete_emailtemplate_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 4.2.13 on 2024-07-05 12:52

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("hub", "0048_alter_featureflag_flag"),
]

operations = [
migrations.DeleteModel(
name="EmailTemplate",
),
migrations.AlterModelOptions(
name="candidateconfirmation",
options={"verbose_name": "Candidate confirmation", "verbose_name_plural": "Candidate confirmations"},
),
migrations.AlterModelOptions(
name="candidatevote",
options={"verbose_name": "Candidate vote", "verbose_name_plural": "Candidate votes"},
),
]
Loading

0 comments on commit e89b296

Please sign in to comment.