1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-12-06 20:07:43 +01:00

Compare commits

...

2 Commits

Author SHA1 Message Date
quark
c908bee872 add email feature 2025-12-05 11:41:05 +01:00
quark
7500c33f0f pdf for guest list 2025-12-03 04:52:04 +01:00
9 changed files with 246 additions and 52 deletions

View File

@@ -24,3 +24,7 @@ WIKI_PASSWORD=
# OIDC # OIDC
OIDC_RSA_PRIVATE_KEY=CHANGE_ME OIDC_RSA_PRIVATE_KEY=CHANGE_ME
# Activity configuration
TRUSTED_ACTIVITY_MAIL=
ACTIVITY_EMAIL_MANAGER=

View File

@@ -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")

View File

@@ -2,7 +2,7 @@
{% comment %} {% comment %}
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}
{% load i18n perms %} {% load i18n perms crispy_forms_tags %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% load static django_tables2 i18n %} {% load static django_tables2 i18n %}
@@ -37,11 +37,20 @@ 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>
<div class="card-body">
<form action="{% url 'activity:guest_pdf' activity_pk=activity.pk %}" method="post">
{% csrf_token %}
{{ email_form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Share" %}</button>
</form>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -118,5 +127,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
errMsg(xhr.responseJSON); 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 %}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,24 @@
{% load i18n %}
{% now "Y-m-d" as today %}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>[Note Kfet] Liste des invité·e·s à l'activité {{ activity.name }}</title>
</head>
<body>
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
--
<p>
Le BDE<br>
{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}
</p>
</body>
</html>

View File

@@ -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" %}

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|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
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,30 +1,38 @@
# 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
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.mail import EmailMultiAlternatives
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.http import HttpResponse, JsonResponse from django.db.models.functions.text import Lower
from django.urls import reverse_lazy from django.http import HttpResponse, JsonResponse, HttpResponseRedirect
from django.urls import reverse, 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
from permission.views import ProtectQuerysetMixin, ProtectedCreateView 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 .models import Activity, Entry, Guest, Opener
from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
@@ -159,51 +167,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 +196,13 @@ 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
context["email_form"] = EmailForm
if 'mail' in self.request.GET:
context["mail"] = self.request.GET['mail'].split(',')
return context return context
@@ -463,6 +433,118 @@ 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):
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 # 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):

View File

@@ -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 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. 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 lenvoyer par mail (solution privilégiée), mais uniquement à une liste
dadresses mail bien précise, vérifiée régulièrement.
Entrées aux soirées Entrées aux soirées
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~