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

Compare commits

..

3 Commits
main ... aasa

Author SHA1 Message Date
alexismdr
00176c3cef fix: add aasa import in urls.py 2025-11-28 09:13:32 +01:00
alexismdr
de841c8143 feat: distribute aasa on .well-known/apple-app-site-association 2025-11-28 02:11:10 +01:00
alexismdr
43603d7359 feat: aasa view json distribution
* basic webcredentials config for password managers

See https://developer.apple.com/documentation/xcode/supporting-associated-domains for ref
2025-11-28 02:08:34 +01:00
12 changed files with 75 additions and 261 deletions

View File

@@ -24,7 +24,3 @@ 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

@@ -7,10 +7,10 @@ stages:
variables: variables:
GIT_SUBMODULE_STRATEGY: recursive GIT_SUBMODULE_STRATEGY: recursive
# Ubuntu 24.04 # Ubuntu 22.04
py312-django52: py310-django52:
stage: test stage: test
image: ubuntu:24.04 image: ubuntu:22.04
before_script: before_script:
# Fix tzdata prompt # Fix tzdata prompt
- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone - ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
@@ -22,12 +22,12 @@ py312-django52:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py312-django52 script: tox -e py310-django52
# Debian Bookworm # Debian Bookworm
py313-django52: py311-django52:
stage: test stage: test
image: debian:trixie image: debian:bookworm
before_script: before_script:
- > - >
apt-get update && apt-get update &&
@@ -37,11 +37,11 @@ py313-django52:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py313-django52 script: tox -e py311-django52
linters: linters:
stage: quality-assurance stage: quality-assurance
image: debian:trixie image: debian:bookworm
before_script: before_script:
- apt-get update && apt-get install -y tox - apt-get update && apt-get install -y tox
script: tox -e linters script: tox -e linters

View File

@@ -120,12 +120,3 @@ 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 crispy_forms_tags %} {% load i18n perms %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% load static django_tables2 i18n %} {% load static django_tables2 i18n %}
@@ -37,20 +37,11 @@ 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">
<a href="{% url 'activity:guest_pdf' activity_pk=activity.pk %}" data-turbolinks="false"> <button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=1&table=guests'">
<button class="btn btn-block btn-danger"><i class="fa fa-file-pdf-o"></i> {% trans "Export to PDF" %}</button> {% trans "Export to CSV" %}
</a> </button>
</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 %}
@@ -127,11 +118,5 @@ 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

@@ -1,30 +0,0 @@
{% 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>
<p>
Bonjour,
</p>
<p>
Vous trouverez en pièce-jointe la liste des invité·e·s à l'activité : {{ activity.name }}
</p>
<p>
Cette liste vous est partagée par {{ user_identity }} (en copie de ce mail).
</p>
<p>
Bonne journée
</p>
--
<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

@@ -1,13 +0,0 @@
{% 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

@@ -1,42 +0,0 @@
\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,7 +10,6 @@ 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,38 +1,30 @@
# 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.db.models.functions.text import Lower from django.http import HttpResponse, JsonResponse
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect from django.urls import reverse_lazy
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, EmailForm from .forms import ActivityForm, GuestForm
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
@@ -167,6 +159,51 @@ 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()
@@ -196,13 +233,6 @@ 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
@@ -433,118 +463,6 @@ 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,10 +107,6 @@ 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
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~

View File

@@ -8,7 +8,7 @@ from django.views.defaults import bad_request, permission_denied, page_not_found
from member.views import CustomLoginView from member.views import CustomLoginView
from .admin import admin_site from .admin import admin_site
from .views import IndexView from .views import IndexView, apple_app_site_association
urlpatterns = [ urlpatterns = [
# Dev so redirect to something random # Dev so redirect to something random
@@ -33,6 +33,9 @@ urlpatterns = [
path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('django.contrib.auth.urls')),
path('api/', include('api.urls')), path('api/', include('api.urls')),
path('permission/', include('permission.urls')), path('permission/', include('permission.urls')),
# Apple App Site Association
path('.well-known/apple-app-site-association', apple_app_site_association),
] ]
# During development, serve static and media files # During development, serve static and media files

View File

@@ -6,6 +6,7 @@ from django.urls import reverse
from django.views.generic import RedirectView from django.views.generic import RedirectView
from note.models import Alias from note.models import Alias
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from django.http import JsonResponse
class IndexView(LoginRequiredMixin, RedirectView): class IndexView(LoginRequiredMixin, RedirectView):
@@ -28,3 +29,13 @@ class IndexView(LoginRequiredMixin, RedirectView):
# Non-Kfet members will don't see the transfer page, but their profile page # Non-Kfet members will don't see the transfer page, but their profile page
return reverse("member:user_detail", args=(user.pk,)) return reverse("member:user_detail", args=(user.pk,))
def apple_app_site_association(request):
data = {
"webcredentials": {
"apps": [
"P5246D3AFQ.org.crans.bde.note"
]
}
}
return JsonResponse(data)