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 bb0fc57a..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 %}
@@ -37,11 +37,20 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% render_table guests %}
+ {% if export %}
+
+
+
+ {% endif %}
{% endif %}
{% endblock %}
@@ -118,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
new file mode 100644
index 00000000..9c6b5bec
--- /dev/null
+++ b/apps/activity/templates/activity/guestlist_sample.tex
@@ -0,0 +1,42 @@
+\documentclass[a4paper,portrait,12pt]{article}
+
+\usepackage{fontspec}
+\usepackage[margin=1.5cm]{geometry}
+\usepackage{longtable}
+
+\begin{document}
+\begin{center}
+\LARGE{Liste des personnes invitées à l'activité « {{ activity.name }} »}
+
+\end{center}
+
+\normalsize
+\noindent En tout,\textbf{ {{total}} }personnes sont invitées à l'activité {{ activity.name }}. \\
+Elle aura lieu du {{ activity.date_start.astimezone.date }} à {{ activity.date_start.astimezone.time }}
+jusqu'au {{ activity.date_end.astimezone.date }} à {{ activity.date_end.astimezone.time }}.
+
+\begin{center}
+\normalsize
+ \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 }} & \\
+\hline
+{% endfor %}
+\end{longtable}
+\end{center}
+
+
+\footnotesize
+\kern -3pt
+\hrule width 2in
+\kern 2.6pt
+\noindent AVERTISSEMENT :
+Cette liste contient des données personnelles (prénom, nom, école)
+et doit être traitée conformément au RGPD.
+Elle ne doit être utilisée que pour les besoins stricts de
+l’organisation de l'activité et ne doit pas être diffusée.
+Toute copie, extraction ou conservation non nécessaire est interdite.
+
+\end{document}
diff --git a/apps/activity/urls.py b/apps/activity/urls.py
index 63a3a169..333f4d1a 100644
--- a/apps/activity/urls.py
+++ b/apps/activity/urls.py
@@ -10,6 +10,7 @@ app_name = 'activity'
urlpatterns = [
path('', views.ActivityListView.as_view(), name='activity_list'),
path('/', views.ActivityDetailView.as_view(), name='activity_detail'),
+ path('/pdf/', views.GuestListRenderView.as_view(), name="guest_pdf"),
path('/invite/', views.ActivityInviteView.as_view(), name='activity_invite'),
path('/entry/', views.ActivityEntryView.as_view(), name='activity_entry'),
path('/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
diff --git a/apps/activity/views.py b/apps/activity/views.py
index 50607ceb..0263590d 100644
--- a/apps/activity/views.py
+++ b/apps/activity/views.py
@@ -1,30 +1,38 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
+import os
+import shutil
+import subprocess
from hashlib import md5
+from tempfile import mkdtemp
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.http import HttpResponse, JsonResponse
-from django.urls import reverse_lazy
+from django.db.models.functions.text import Lower
+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 _
+from django.template.loader import render_to_string
from django.views import View
from django.views.decorators.cache import cache_page
from django.views.generic import DetailView, TemplateView, UpdateView
from django.views.generic.list import ListView
from django_tables2.views import MultiTableMixin, SingleTableMixin
+from note_kfet.settings import BASE_DIR
from api.viewsets import is_regex
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
@@ -159,51 +167,6 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
.distinct(),
]
- def render_to_response(self, context, **response_kwargs):
- """
- Gère l'export CSV manuel pour MultiTableMixin.
- """
- if "_export" in self.request.GET:
- import tablib
- table_name = self.request.GET.get("table")
- if table_name:
- tables = self.get_tables()
- data_list = self.get_tables_data()
-
- for t, d in zip(tables, data_list):
- if t.prefix == table_name:
- # Préparer le CSV
- dataset = tablib.Dataset()
- columns = list(t.base_columns) # noms des colonnes
- dataset.headers = columns
-
- for row in d:
- values = []
- for col in columns:
- try:
- val = getattr(row, col, "")
- # Gestion spéciale pour la colonne 'entry'
- if col == "entry":
- if getattr(row, "has_entry", False):
- val = timezone.localtime(row.entry.time).strftime("%Y-%m-%d %H:%M:%S")
- else:
- val = ""
- values.append(str(val) if val is not None else "")
- except Exception: # RelatedObjectDoesNotExist ou autre
- values.append("")
- dataset.append(values)
-
- csv_bytes = dataset.export("csv")
- if isinstance(csv_bytes, str):
- csv_bytes = csv_bytes.encode("utf-8")
-
- response = HttpResponse(csv_bytes, content_type="text/csv")
- response["Content-Disposition"] = f'attachment; filename="{table_name}.csv"'
- return response
-
- # Sinon rendu normal
- return super().render_to_response(context, **response_kwargs)
-
def get_context_data(self, **kwargs):
context = super().get_context_data()
@@ -233,6 +196,13 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
context["entries_count"] = {self.object: 0}
context["show_entries"] = {self.object: False}
+ guests = Guest.objects.filter(activity=self.object)
+ 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
@@ -463,6 +433,118 @@ class ActivityEntryView(LoginRequiredMixin, SingleTableMixin, TemplateView):
return context
+class GuestListRenderView(LoginRequiredMixin, View):
+ """
+ Render a generated PDF with the given information and a LaTeX template
+ """
+
+ def get_queryset(self, **kwargs):
+ qs = Guest.objects.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))
+ qs = qs.filter(activity__pk=self.kwargs["activity_pk"]).order_by(
+ Lower('last_name'),
+ Lower('first_name'),
+ 'id',
+ )
+
+ 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"])
+
+ if not qs.exists() or qs.count() != Guest.objects.filter(activity=activity).count():
+ raise PermissionDenied(_("You are not allowed to export the guest list for this activity."))
+
+ # Fill the template with the information
+ tex = render_to_string("activity/guestlist_sample.tex", dict(guests=qs.all(), activity=activity, total=qs.count()))
+
+ try:
+ os.mkdir(BASE_DIR + "/tmp")
+ except FileExistsError:
+ pass
+ # We render the file in a temporary directory
+ tmp_dir = mkdtemp(prefix=BASE_DIR + "/tmp/")
+
+ try:
+ with open("{}/guest-list.tex".format(tmp_dir), "wb") as f:
+ f.write(tex.encode("UTF-8"))
+ del tex
+
+ with open(os.devnull, "wb") as devnull:
+ error = subprocess.Popen(
+ ["/usr/bin/xelatex", "-interaction=nonstopmode", "{}/guest-list.tex".format(tmp_dir)],
+ cwd=tmp_dir,
+ stderr=devnull,
+ stdout=devnull,
+ ).wait()
+
+ if error:
+ with open("{}/guest-list.log".format(tmp_dir), "r") as f:
+ log = f.read()
+ raise IOError("An error attempted while generating a Guest list (code=" + str(error) + ")\n\n" + log)
+
+ with open("{}/guest-list.pdf".format(tmp_dir), 'rb') as f:
+ pdf = f.read()
+ 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')
class CalendarView(View):
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
~~~~~~~~~~~~~~~~~~~