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:
@@ -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 %}
|
||||||
|
|||||||
42
apps/activity/templates/activity/guestlist_sample.tex
Normal file
42
apps/activity/templates/activity/guestlist_sample.tex
Normal 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
|
||||||
|
l’organisation de l'activité et ne doit pas être diffusée.
|
||||||
|
Toute copie, extraction ou conservation non nécessaire est interdite.
|
||||||
|
|
||||||
|
\end{document}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user