1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-12-03 18:44:52 +01:00

pdf for guest list

This commit is contained in:
quark
2025-12-03 04:47:44 +01:00
parent 13171899c2
commit 7500c33f0f
4 changed files with 124 additions and 48 deletions

View File

@@ -37,11 +37,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div id="guests_table"> <div id="guests_table">
{% render_table guests %} {% render_table guests %}
</div> </div>
{% if export %}
<div class="card-footer text-center"> <div class="card-footer text-center">
<button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=1&table=guests'"> <a href="{% url 'activity:guest_pdf' activity_pk=activity.pk %}" data-turbolinks="false">
{% trans "Export to CSV" %} <button class="btn btn-block btn-danger"><i class="fa fa-file-pdf-o"></i> {% trans "Export to PDF" %}</button>
</button> </a>
</div> </div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -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
lorganisation de l'activité et ne doit pas être diffusée.
Toute copie, extraction ou conservation non nécessaire est interdite.
\end{document}

View File

@@ -10,6 +10,7 @@ app_name = 'activity'
urlpatterns = [ urlpatterns = [
path('', views.ActivityListView.as_view(), name='activity_list'), path('', views.ActivityListView.as_view(), name='activity_list'),
path('<int:pk>/', views.ActivityDetailView.as_view(), name='activity_detail'), path('<int:pk>/', views.ActivityDetailView.as_view(), name='activity_detail'),
path('<int:activity_pk>/pdf/', views.GuestListRenderView.as_view(), name="guest_pdf"),
path('<int:pk>/invite/', views.ActivityInviteView.as_view(), name='activity_invite'), path('<int:pk>/invite/', views.ActivityInviteView.as_view(), name='activity_invite'),
path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'), path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'),
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'), path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),

View File

@@ -1,7 +1,11 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import os
import shutil
import subprocess
from hashlib import md5 from hashlib import md5
from tempfile import mkdtemp
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin 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.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.db.models.functions.text import Lower
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from django.views import View from django.views import View
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from django.views.generic import DetailView, TemplateView, UpdateView from django.views.generic import DetailView, TemplateView, UpdateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django_tables2.views import MultiTableMixin, SingleTableMixin from django_tables2.views import MultiTableMixin, SingleTableMixin
from note_kfet.settings import BASE_DIR
from api.viewsets import is_regex from api.viewsets import is_regex
from note.models import Alias, NoteSpecial, NoteUser from note.models import Alias, NoteSpecial, NoteUser
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
@@ -159,51 +166,6 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
.distinct(), .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): def get_context_data(self, **kwargs):
context = super().get_context_data() context = super().get_context_data()
@@ -233,6 +195,10 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
context["entries_count"] = {self.object: 0} context["entries_count"] = {self.object: 0}
context["show_entries"] = {self.object: False} 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 return context
@@ -463,6 +429,71 @@ class ActivityEntryView(LoginRequiredMixin, SingleTableMixin, TemplateView):
return context 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 # Cache for 1 hour
@method_decorator(cache_page(60 * 60), name='dispatch') @method_decorator(cache_page(60 * 60), name='dispatch')
class CalendarView(View): class CalendarView(View):