diff --git a/backend/civil_society_vote/common/__init__.py b/backend/civil_society_vote/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/civil_society_vote/common/messaging.py b/backend/civil_society_vote/common/messaging.py new file mode 100644 index 00000000..1432914e --- /dev/null +++ b/backend/civil_society_vote/common/messaging.py @@ -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) diff --git a/backend/civil_society_vote/settings.py b/backend/civil_society_vote/settings.py index 0451d5de..651be935 100644 --- a/backend/civil_society_vote/settings.py +++ b/backend/civil_society_vote/settings.py @@ -168,6 +168,7 @@ "crispy_forms", "django_crispy_bulma", "storages", + "django_q", "django_recaptcha", "file_resubmit", "rangefilter", diff --git a/backend/hub/admin.py b/backend/hub/admin.py index 49038c29..f9c1b05a 100644 --- a/backend/hub/admin.py +++ b/backend/hub/admin.py @@ -1,7 +1,6 @@ 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 @@ -9,26 +8,25 @@ 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, @@ -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) @@ -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", ) @@ -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"] diff --git a/backend/hub/forms.py b/backend/hub/forms.py index df4bf87f..5c0d9072 100644 --- a/backend/hub/forms.py +++ b/backend/hub/forms.py @@ -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", @@ -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, ) diff --git a/backend/hub/management/commands/init.py b/backend/hub/management/commands/init.py index 05dc5eaa..b54a1c1b 100644 --- a/backend/hub/management/commands/init.py +++ b/backend/hub/management/commands/init.py @@ -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): @@ -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!")) diff --git a/backend/hub/migrations/0049_delete_emailtemplate_and_more.py b/backend/hub/migrations/0049_delete_emailtemplate_and_more.py new file mode 100644 index 00000000..ba882d5e --- /dev/null +++ b/backend/hub/migrations/0049_delete_emailtemplate_and_more.py @@ -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"}, + ), + ] diff --git a/backend/hub/models.py b/backend/hub/models.py index aea0c2e6..ba091bf0 100644 --- a/backend/hub/models.py +++ b/backend/hub/models.py @@ -1,7 +1,6 @@ import logging from ckeditor_uploader.fields import RichTextUploadingField -from ckeditor.fields import RichTextField from django.conf import settings from django.contrib.auth.models import Group from django.core.exceptions import ValidationError @@ -16,7 +15,6 @@ from accounts.models import User - # NOTE: If you change the group names here, make sure you also update the names in the live database before deployment STAFF_GROUP = "Code4Romania Staff" COMMITTEE_GROUP = "Comisie Electorala" @@ -99,14 +97,6 @@ def select_public_storage(): ("global_support_round", _("Enable global support (the support of at least 10 organizations is required)")), ) -EMAIL_TEMPLATE_CHOICES = Choices( - ("pending_orgs_digest", _("Pending organizations digest email")), - ("org_approved", _("Your organization was approved")), - ("org_rejected", _("Your organization was rejected")), - ("vote_audit", _("Vote audit log")), - ("confirmation", _("Confirmation email")), -) - def get_feature_flag(flag_choice: str) -> bool: if not flag_choice or flag_choice not in FLAG_CHOICES: @@ -166,19 +156,6 @@ def __str__(self): return self.title -class EmailTemplate(TimeStampedModel): - template = models.CharField(_("Template"), choices=EMAIL_TEMPLATE_CHOICES, max_length=254, unique=True) - text_content = models.TextField(_("Text content")) - html_content = RichTextField(_("HTML content"), blank=True) - - class Meta: - verbose_name = _("Email template") - verbose_name_plural = _("Email templates") - - def __str__(self): - return f"{EMAIL_TEMPLATE_CHOICES[self.template]}" - - class Domain(TimeStampedModel): name = models.CharField(_("Name"), max_length=254, unique=True) description = models.TextField(_("Description")) @@ -458,9 +435,7 @@ class Candidate(StatusModel, TimeStampedModel): ("rejected", _("Rejected")), ) - org = models.OneToOneField( - "Organization", on_delete=models.CASCADE, related_name="candidate", null=True, blank=True - ) + org = models.OneToOneField(Organization, on_delete=models.CASCADE, related_name="candidate", null=True, blank=True) initial_org = models.ForeignKey( "Organization", on_delete=models.CASCADE, @@ -545,6 +520,7 @@ def save(self, *args, **kwargs): # in the same time keeping the old candidate record and backwards compatibility with the one-to-one relations # that are used in the rest of the codebase. # TODO: Refactor this flow to make it less hacky and have a single relationship back to organization. + self.org: Organization if self.id and self.initial_org: self.org = None self.is_proposed = False @@ -564,7 +540,7 @@ class CandidateVote(TimeStampedModel): domain = models.ForeignKey("Domain", on_delete=models.PROTECT, related_name="votes") class Meta: - verbose_name_plural = _("Canditate votes") + verbose_name_plural = _("Candidate votes") verbose_name = _("Candidate vote") constraints = [ models.UniqueConstraint(fields=["user", "candidate"], name="unique_candidate_vote"), @@ -603,7 +579,7 @@ class CandidateConfirmation(TimeStampedModel): candidate = models.ForeignKey("Candidate", on_delete=models.CASCADE, related_name="confirmations") class Meta: - verbose_name_plural = _("Canditate confirmations") + verbose_name_plural = _("Candidate confirmations") verbose_name = _("Candidate confirmation") constraints = [ models.UniqueConstraint(fields=["user", "candidate"], name="unique_candidate_confirmation"), diff --git a/backend/hub/templates/emails/01_pending_orgs_digest.html b/backend/hub/templates/emails/01_pending_orgs_digest.html new file mode 100644 index 00000000..d6e9504c --- /dev/null +++ b/backend/hub/templates/emails/01_pending_orgs_digest.html @@ -0,0 +1,23 @@ +{% extends "emails/base_email.html" %} + +{% block content %} + +
+ Un număr de {{ nr_pending_orgs }} organizații așteaptă aprobarea voastră pe platforma VotONG. +
+ ++ Accesați adresa + {{ committee_org_list_url }} + pentru a vizualiza ultimele aplicații. +
+ +
+ Vă mulțumim!
+
+ Echipa VotONG
+
+ Cererea de înscriere a organizației "{{ name }}" în platforma VotONG a fost aprobată. +
+ ++ Pentru a vă loga mergeți la adresa + {{ site_url }}. +
+ +
+ Va mulțumim!
+
+ Echipa VotONG
+
+ Organizația "{{ name }}" a fost respinsă din platforma VotONG. +
+ + {% if rejection_message %} ++ Motivul respingerii: "{{ rejection_message }}" +
+ {% endif %} + +
+ Va mulțumim!
+
+ Echipa VotONG
+
+ Organizație: {{ org_link }}
+
+ Candidat: {{ candidate_link }}
+
+ Urmează acest link pentru a le confirma acțiunea: + {{ confirmation_link }} +
+{% endblock %} diff --git a/backend/hub/templates/emails/05_confirmation.txt b/backend/hub/templates/emails/05_confirmation.txt new file mode 100644 index 00000000..5ffdea85 --- /dev/null +++ b/backend/hub/templates/emails/05_confirmation.txt @@ -0,0 +1,3 @@ +Administratorul site-ului votong.ro a schimbatul statusul candidatului "{{ candidate }}" în "{{ status }}". + +Urmează acest link pentru a le confirma acțiunea: {{ confirmation_link }} diff --git a/backend/hub/templates/emails/06_contact.html b/backend/hub/templates/emails/06_contact.html new file mode 100644 index 00000000..d191e075 --- /dev/null +++ b/backend/hub/templates/emails/06_contact.html @@ -0,0 +1,24 @@ +{% extends "emails/base_email.html" %} + +{% block content %} ++ Nume: {{ name }} +
+ ++ Email: {{ email }} +
+ ++ Mesaj: +
++ {{ message }} +
+