From c908bee872f2463b5e2d5f7fa2e244f657632e7f Mon Sep 17 00:00:00 2001 From: quark Date: Fri, 5 Dec 2025 11:41:05 +0100 Subject: [PATCH] add email feature --- .env_example | 4 ++ apps/activity/forms.py | 9 +++ .../templates/activity/activity_detail.html | 15 ++++- .../templates/activity/guest_list.html | 24 +++++++ .../templates/activity/guest_list.txt | 13 ++++ .../templates/activity/guestlist_sample.tex | 6 +- apps/activity/views.py | 63 +++++++++++++++++-- docs/apps/activity.rst | 4 ++ 8 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 apps/activity/templates/activity/guest_list.html create mode 100644 apps/activity/templates/activity/guest_list.txt diff --git a/.env_example b/.env_example index da0b4efa..499b6f6a 100644 --- a/.env_example +++ b/.env_example @@ -24,3 +24,7 @@ WIKI_PASSWORD= # OIDC OIDC_RSA_PRIVATE_KEY=CHANGE_ME + +# Activity configuration +TRUSTED_ACTIVITY_MAIL= +ACTIVITY_EMAIL_MANAGER= diff --git a/apps/activity/forms.py b/apps/activity/forms.py index a865ece6..cfa1a7fc 100644 --- a/apps/activity/forms.py +++ b/apps/activity/forms.py @@ -120,3 +120,12 @@ class GuestForm(forms.ModelForm): }, ), } + + +class EmailForm(forms.Form): + """ + Form to export guest list by email + """ + emails = forms.CharField() + emails.label = _("Emails") + emails.widget.attrs['placeholder'] = _("Emails, separated by a comma") diff --git a/apps/activity/templates/activity/activity_detail.html b/apps/activity/templates/activity/activity_detail.html index f119c797..63aba60d 100644 --- a/apps/activity/templates/activity/activity_detail.html +++ b/apps/activity/templates/activity/activity_detail.html @@ -2,7 +2,7 @@ {% comment %} SPDX-License-Identifier: GPL-3.0-or-later {% endcomment %} -{% load i18n perms %} +{% load i18n perms crispy_forms_tags %} {% load render_table from django_tables2 %} {% load static django_tables2 i18n %} @@ -43,6 +43,13 @@ SPDX-License-Identifier: GPL-3.0-or-later +
+
+ {% csrf_token %} + {{ email_form|crispy }} + +
+
{% endif %} {% endif %} @@ -120,5 +127,11 @@ SPDX-License-Identifier: GPL-3.0-or-later errMsg(xhr.responseJSON); }); }); + {% if mail %} + var mails = {{ mail|safe }}; + for (const mail of mails) { + addMsg(gettext("An email has been sent to") + " " + mail, "success"); + } + {% endif %} {% endblock %} diff --git a/apps/activity/templates/activity/guest_list.html b/apps/activity/templates/activity/guest_list.html new file mode 100644 index 00000000..b5a98564 --- /dev/null +++ b/apps/activity/templates/activity/guest_list.html @@ -0,0 +1,24 @@ +{% load i18n %} +{% now "Y-m-d" as today %} + + + + + [Note Kfet] Liste des invité·e·s à l'activité {{ activity.name }} + + + +Bonjour, + +Vous trouverez en pièce-jointe la liste des invité·e·s à l'activité : {{ activity.name }} +Cette liste vous est partagée par {{ user_identity }} (en copie de ce mail). + +Bonne journée + +-- +

+ Le BDE
+ {% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %} +

+ + diff --git a/apps/activity/templates/activity/guest_list.txt b/apps/activity/templates/activity/guest_list.txt new file mode 100644 index 00000000..36cc2798 --- /dev/null +++ b/apps/activity/templates/activity/guest_list.txt @@ -0,0 +1,13 @@ +{% load i18n %} + +Bonjour, + +Vous trouverez en pièce-jointe la liste des invité·e·s à l'activité : {{ activity.name }} +Cette liste vous est partagée par {{ user_identity }} (en copie de ce mail). + +Bonne journée + +-- +Le BDE + +{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %} diff --git a/apps/activity/templates/activity/guestlist_sample.tex b/apps/activity/templates/activity/guestlist_sample.tex index ff60053c..9c6b5bec 100644 --- a/apps/activity/templates/activity/guestlist_sample.tex +++ b/apps/activity/templates/activity/guestlist_sample.tex @@ -17,11 +17,11 @@ jusqu'au {{ activity.date_end.astimezone.date }} à {{ activity.date_end.astimez \begin{center} \normalsize - \begin{longtable}{c||c|c|c} -& \textbf{Nom} & \textbf{Prénom} & \textbf{École} \\ + \begin{longtable}{c||c|c|c|c|} +& \textbf{Nom} & \textbf{Prénom} & \textbf{École} & \textbf{Entrée} \\ \hline\hline {% for guest in guests %} -{{ forloop.counter }} & {{ guest.last_name|safe }} & {{ guest.first_name|safe }} & {{ guest.school|safe }} \\ +{{ forloop.counter }} & {{ guest.last_name|safe }} & {{ guest.first_name|safe }} & {{ guest.school|safe }} & \\ \hline {% endfor %} \end{longtable} diff --git a/apps/activity/views.py b/apps/activity/views.py index 684e51ca..0263590d 100644 --- a/apps/activity/views.py +++ b/apps/activity/views.py @@ -11,11 +11,12 @@ from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied +from django.core.mail import EmailMultiAlternatives from django.db import transaction from django.db.models import F, Q from django.db.models.functions.text import Lower -from django.http import HttpResponse, JsonResponse -from django.urls import reverse_lazy +from django.http import HttpResponse, JsonResponse, HttpResponseRedirect +from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ @@ -31,7 +32,7 @@ from note.models import Alias, NoteSpecial, NoteUser from permission.backends import PermissionBackend from permission.views import ProtectQuerysetMixin, ProtectedCreateView -from .forms import ActivityForm, GuestForm +from .forms import ActivityForm, GuestForm, EmailForm from .models import Activity, Entry, Guest, Opener from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable @@ -199,6 +200,9 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix guests_view = guests.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")) if guests.exists() and guests.count() == guests_view.count(): context["export"] = True + context["email_form"] = EmailForm + if 'mail' in self.request.GET: + context["mail"] = self.request.GET['mail'].split(',') return context @@ -445,6 +449,29 @@ class GuestListRenderView(LoginRequiredMixin, View): return qs.distinct() def get(self, request, **kwargs): + pdf = self.generate_pdf(request) + return self.view_pdf(request, pdf) + + def post(self, request, **kwargs): + recipients = [] + emails = request.POST['emails'].split(',') + trust_address = os.getenv('TRUSTED_ACTIVITY_MAIL', '').split(',') + for email_address in emails: + if email_address in trust_address: + recipients.append(email_address) + # don't send email if no recipient + if not recipients: + raise PermissionDenied(_("Emails are not trusted!")) + pdf = self.generate_pdf(request) + self.send_pdf(request, recipients, pdf) + url = reverse('activity:activity_detail', kwargs={"pk": self.kwargs["activity_pk"]}) + url += '?mail=' + for email in recipients: + url += email + ',' + url = url[:-1] # delete last comma + return HttpResponseRedirect(url) + + def generate_pdf(self, request, **kwargs): qs = self.get_queryset() activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) @@ -480,19 +507,43 @@ class GuestListRenderView(LoginRequiredMixin, View): log = f.read() raise IOError("An error attempted while generating a Guest list (code=" + str(error) + ")\n\n" + log) - # Display the generated pdf as a HTTP Response with open("{}/guest-list.pdf".format(tmp_dir), 'rb') as f: pdf = f.read() - response = HttpResponse(pdf, content_type="application/pdf") - response['Content-Disposition'] = "inline;filename=Liste des invité·e·s.pdf" + return pdf + except IOError as e: raise e finally: # Delete all temporary files shutil.rmtree(tmp_dir) + def view_pdf(self, request, pdf): + response = HttpResponse(pdf, content_type="application/pdf") + response['Content-Disposition'] = "inline;filename=Liste des invité·e·s.pdf" return response + def send_pdf(self, request, recipients, pdf): + user_identity = request.user.first_name.capitalize() + ' ' + request.user.last_name.upper() + activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) + subject = _(f"Guest list of the activity {activity.name} share by {user_identity}") + # add the user in cc + cc = [request.user.email] + context = {'activity': activity, 'user_identity': user_identity} + message = render_to_string("activity/guest_list.txt", context=context) + html_message = render_to_string("activity/guest_list.html", context=context) + if os.getenv('ACTIVITY_EMAIL_MANAGER', ''): + cc.append(os.getenv('ACTIVITY_EMAIL_MANAGER')) + email = EmailMultiAlternatives( + subject=subject, + to=recipients, + cc=cc, + body=message, + ) + email.attach("Liste des invité·e·s.pdf", pdf) + email.attach_alternative(html_message, "text/html") + email.send() + return + # Cache for 1 hour @method_decorator(cache_page(60 * 60), name='dispatch') diff --git a/docs/apps/activity.rst b/docs/apps/activity.rst index abdcd3e5..0282e1f1 100644 --- a/docs/apps/activity.rst +++ b/docs/apps/activity.rst @@ -107,6 +107,10 @@ N'importe qui peut inviter des ami⋅es non adhérent⋅es, tant que les contrai trois personnes par activité et une personne ne peut pas être invitée plus de 5 fois par an). L'invitation est facturée à l'entrée. +Les ayant-droit peuvent également générer la liste des invité·e·s au format PDF afin de la transmettre +aux vigiles. Iels peuvent aussi l’envoyer par mail (solution privilégiée), mais uniquement à une liste +d’adresses mail bien précise, vérifiée régulièrement. + Entrées aux soirées ~~~~~~~~~~~~~~~~~~~