From 7500c33f0f4f5e6eafe91aafac6bb224d5c61171 Mon Sep 17 00:00:00 2001 From: quark Date: Wed, 3 Dec 2025 04:47:44 +0100 Subject: [PATCH] pdf for guest list --- .../templates/activity/activity_detail.html | 8 +- .../templates/activity/guestlist_sample.tex | 42 ++++++ apps/activity/urls.py | 1 + apps/activity/views.py | 121 +++++++++++------- 4 files changed, 124 insertions(+), 48 deletions(-) create mode 100644 apps/activity/templates/activity/guestlist_sample.tex 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):