diff --git a/apps/activity/templates/activity/activity_detail.html b/apps/activity/templates/activity/activity_detail.html
index bb0fc57a..f119c797 100644
--- a/apps/activity/templates/activity/activity_detail.html
+++ b/apps/activity/templates/activity/activity_detail.html
@@ -37,11 +37,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% render_table guests %}
+ {% if export %}
+ {% endif %}
{% endif %}
{% endblock %}
diff --git a/apps/activity/templates/activity/guestlist_sample.tex b/apps/activity/templates/activity/guestlist_sample.tex
new file mode 100644
index 00000000..ff60053c
--- /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}
+& \textbf{Nom} & \textbf{Prénom} & \textbf{École} \\
+\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..684e51ca 100644
--- a/apps/activity/views.py
+++ b/apps/activity/views.py
@@ -1,7 +1,11 @@
# 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
@@ -9,16 +13,19 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
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.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
@@ -159,51 +166,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 +195,10 @@ 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
return context
@@ -463,6 +429,71 @@ 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):
+ 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)
+
+ # 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"
+ except IOError as e:
+ raise e
+ finally:
+ # Delete all temporary files
+ shutil.rmtree(tmp_dir)
+
+ return response
+
+
# Cache for 1 hour
@method_decorator(cache_page(60 * 60), name='dispatch')
class CalendarView(View):