Add RGPD data access/deletion forms and admin Analytics dashboard

RGPD (/rgpd):
- Access form: search by IP, generate PDF with all visitor data, email it
- Deletion form: delete all visitor data by IP, generate attestation PDF
- Both forms pre-fill client IP, require email for response
- PDF templates with E-Cosplay branding, RGPD article references

Admin Analytics (/admin/analytics):
- KPIs: unique visitors, pageviews, pages/visitor
- Top pages and referrers tables
- Device type, browser, OS breakdowns
- Period filter: today, 7d, 30d, all

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-26 11:59:34 +01:00
parent 809a1055ec
commit 2ae28089d5
10 changed files with 576 additions and 3 deletions

View File

@@ -0,0 +1,124 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Analytics{% endblock %}
{% block body %}
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Analytics</h1>
<div class="flex gap-2">
{% for key, label in {today: "Aujourd'hui", '7d': '7 jours', '30d': '30 jours', all: 'Tout'} %}
<a href="{{ path('app_admin_analytics', {period: key}) }}"
class="px-3 py-1.5 text-xs font-black uppercase tracking-widest border-2 transition-all {{ period == key ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-300 bg-white hover:bg-gray-100' }}">
{{ label }}
</a>
{% endfor %}
</div>
</div>
{# KPIs #}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="admin-card text-center">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Visiteurs uniques</p>
<p class="text-3xl font-black">{{ visitors|number_format(0, '.', ' ') }}</p>
</div>
<div class="admin-card text-center">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Pages vues</p>
<p class="text-3xl font-black">{{ pageviews|number_format(0, '.', ' ') }}</p>
</div>
<div class="admin-card text-center">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Pages / visiteur</p>
<p class="text-3xl font-black">{{ visitors > 0 ? (pageviews / visitors)|number_format(1) : '0' }}</p>
</div>
<div class="admin-card text-center">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Periode</p>
<p class="text-xl font-black">{{ {today: "Aujourd'hui", '7d': '7 derniers jours', '30d': '30 derniers jours', all: 'Depuis le debut'}[period] }}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{# Top pages #}
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Pages les plus visitees</h2>
{% if top_pages|length > 0 %}
<div class="overflow-x-auto">
<table class="admin-table w-full">
<thead>
<tr>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300">URL</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Vues</th>
</tr>
</thead>
<tbody>
{% for page in top_pages %}
<tr>
<td class="text-sm font-bold truncate max-w-[300px]">{{ page.url }}</td>
<td class="text-right font-black text-sm">{{ page.hits }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-400">Aucune donnee.</p>
{% endif %}
</div>
{# Top referrers #}
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Referrers</h2>
{% if top_referrers|length > 0 %}
<div class="overflow-x-auto">
<table class="admin-table w-full">
<thead>
<tr>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300">Source</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Vues</th>
</tr>
</thead>
<tbody>
{% for ref in top_referrers %}
<tr>
<td class="text-sm font-bold truncate max-w-[300px]">{{ ref.referrer }}</td>
<td class="text-right font-black text-sm">{{ ref.hits }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-400">Aucun referrer.</p>
{% endif %}
</div>
</div>
{# Devices, Browsers, OS #}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Appareils</h2>
{% for d in devices %}
<div class="flex justify-between py-1 text-sm">
<span class="font-bold text-gray-500">{{ d.deviceType|capitalize }}</span>
<span class="font-black">{{ d.cnt }}</span>
</div>
{% endfor %}
</div>
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Navigateurs</h2>
{% for b in browsers %}
<div class="flex justify-between py-1 text-sm">
<span class="font-bold text-gray-500">{{ b.browser }}</span>
<span class="font-black">{{ b.cnt }}</span>
</div>
{% endfor %}
</div>
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Systemes</h2>
{% for o in os_list %}
<div class="flex justify-between py-1 text-sm">
<span class="font-bold text-gray-500">{{ o.os }}</span>
<span class="font-black">{{ o.cnt }}</span>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -24,6 +24,7 @@
<a href="{{ path('app_admin_events') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_event' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Evenements</a>
<a href="{{ path('app_admin_orders') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_orders' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Commandes</a>
<a href="{{ path('app_admin_logs') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_logs' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Logs</a>
<a href="{{ path('app_admin_analytics') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_analytics' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Analytics</a>
<a href="{{ path('app_admin_infra') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_infra' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Infra</a>
</nav>
</div>

View File

@@ -0,0 +1,7 @@
{% extends 'emails/base.html.twig' %}
{% block content %}
<p>Bonjour,</p>
<p>Suite a votre demande d'acces a vos donnees personnelles (article 15 du RGPD), vous trouverez en piece jointe un document PDF contenant l'ensemble des donnees que nous detenons vous concernant.</p>
<p>Si vous souhaitez exercer votre droit a l'effacement, vous pouvez le faire depuis notre page RGPD.</p>
<p>Cordialement,<br>L'equipe E-Ticket</p>
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends 'emails/base.html.twig' %}
{% block content %}
<p>Bonjour,</p>
<p>Suite a votre demande de suppression de vos donnees personnelles (article 17 du RGPD), nous vous confirmons que vos donnees ont ete supprimees de nos systemes.</p>
<p>Vous trouverez en piece jointe une attestation de suppression.</p>
<p>Cordialement,<br>L'equipe E-Ticket</p>
{% endblock %}

View File

@@ -151,7 +151,56 @@
<p>Tout litige en relation avec le traitement des donnees personnelles est soumis au droit francais. Il est fait attribution exclusive de juridiction aux tribunaux competents de Laon.</p>
</section>
<p class="text-sm opacity-70 italic">Derniere mise a jour : {{ "now"|date("d/m/Y") }}</p>
<p class="text-sm opacity-70 italic mb-8">Derniere mise a jour : {{ "now"|date("d/m/Y") }}</p>
{% for message in app.flashes('success') %}
<div class="flash-success mb-4"><p class="font-black text-sm">{{ message }}</p></div>
{% endfor %}
{% for message in app.flashes('error') %}
<div class="flash-error mb-4"><p class="font-black text-sm">{{ message }}</p></div>
{% endfor %}
<section id="exercer-droits">
<h2 class="text-xl font-black uppercase mb-4">Exercer vos droits</h2>
<div class="flex flex-col lg:flex-row gap-6">
<div class="flex-1 border-2 border-gray-900 p-6">
<h3 class="text-lg font-black uppercase mb-2">Droit d'acces</h3>
<p class="text-sm text-gray-600 mb-4">Recevez par email un PDF contenant toutes les donnees de navigation que nous detenons vous concernant (article 15 du RGPD).</p>
<form method="post" action="{{ path('app_rgpd_access') }}" class="flex flex-col gap-3">
<div>
<label for="access_email" class="text-xs font-black uppercase block mb-1">Email</label>
<input type="email" id="access_email" name="email" required placeholder="votre@email.fr" class="w-full px-3 py-2 border-2 border-gray-900 font-bold text-sm">
</div>
<div>
<label for="access_ip" class="text-xs font-black uppercase block mb-1">Adresse IP</label>
<input type="text" id="access_ip" name="ip" required value="{{ client_ip }}" class="w-full px-3 py-2 border-2 border-gray-900 font-bold text-sm">
</div>
<button type="submit" class="px-4 py-2 border-2 border-gray-900 bg-[#fabf04] font-black uppercase text-xs tracking-widest cursor-pointer hover:bg-yellow-500 transition-all">
Demander mes donnees
</button>
</form>
</div>
<div class="flex-1 border-2 border-red-600 p-6">
<h3 class="text-lg font-black uppercase mb-2">Droit a l'effacement</h3>
<p class="text-sm text-gray-600 mb-4">Supprimez toutes vos donnees de navigation de nos systemes et recevez une attestation par email (article 17 du RGPD).</p>
<form method="post" action="{{ path('app_rgpd_deletion') }}" data-confirm="Cette action est irreversible. Toutes vos donnees de navigation seront definitivement supprimees. Continuer ?" class="flex flex-col gap-3">
<div>
<label for="delete_email" class="text-xs font-black uppercase block mb-1">Email</label>
<input type="email" id="delete_email" name="email" required placeholder="votre@email.fr" class="w-full px-3 py-2 border-2 border-gray-900 font-bold text-sm">
</div>
<div>
<label for="delete_ip" class="text-xs font-black uppercase block mb-1">Adresse IP</label>
<input type="text" id="delete_ip" name="ip" required value="{{ client_ip }}" class="w-full px-3 py-2 border-2 border-gray-900 font-bold text-sm">
</div>
<button type="submit" class="px-4 py-2 border-2 border-red-600 bg-red-600 text-white font-black uppercase text-xs tracking-widest cursor-pointer hover:bg-red-800 transition-all">
Supprimer mes donnees
</button>
</form>
</div>
</div>
</section>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><style>
body { font-family: DejaVu Sans, sans-serif; font-size: 11px; color: #111; }
h1 { font-size: 18px; margin-bottom: 5px; }
h2 { font-size: 14px; margin-top: 20px; border-bottom: 1px solid #ccc; padding-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 10px; }
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
th { background: #f5f5f5; font-weight: bold; }
.header { display: flex; align-items: center; margin-bottom: 15px; }
.logo { width: 60px; margin-right: 15px; }
.meta { color: #666; font-size: 10px; margin-bottom: 15px; }
</style></head>
<body>
{% if logo %}<img src="{{ logo }}" class="logo" alt="Logo">{% endif %}
<h1>Droit d'acces aux donnees personnelles</h1>
<p class="meta">Article 15 du RGPD — Genere le {{ date|date('d/m/Y a H:i') }}</p>
<p>Adresse IP concernee : <strong>{{ ip }}</strong></p>
{% for entry in data %}
<h2>Session #{{ loop.index }}{{ entry.visitor.createdAt|date('d/m/Y H:i') }}</h2>
<table>
<tr><th>Appareil</th><td>{{ entry.visitor.deviceType }}</td><th>OS</th><td>{{ entry.visitor.os ?? 'Inconnu' }}</td></tr>
<tr><th>Navigateur</th><td>{{ entry.visitor.browser ?? 'Inconnu' }}</td><th>Langue</th><td>{{ entry.visitor.language ?? 'Inconnu' }}</td></tr>
<tr><th>Ecran</th><td>{{ entry.visitor.screenWidth ?? '?' }}x{{ entry.visitor.screenHeight ?? '?' }}</td><th>Compte lie</th><td>{{ entry.visitor.user ? entry.visitor.user.email : 'Non' }}</td></tr>
</table>
{% if entry.events|length > 0 %}
<table>
<thead><tr><th>Date</th><th>Page</th><th>Titre</th><th>Referrer</th></tr></thead>
<tbody>
{% for event in entry.events %}
<tr>
<td>{{ event.createdAt|date('d/m/Y H:i') }}</td>
<td>{{ event.url }}</td>
<td>{{ event.title ?? '-' }}</td>
<td>{{ event.referrer ?? '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p><em>Aucune page visitee enregistree.</em></p>
{% endif %}
{% endfor %}
<p style="margin-top: 20px; font-size: 10px; color: #666;">
Document genere automatiquement par E-Ticket (Association E-Cosplay) conformement au Reglement General sur la Protection des Donnees (RGPD).
Pour toute question : contact@e-cosplay.fr
</p>
</body>
</html>

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><style>
body { font-family: DejaVu Sans, sans-serif; font-size: 12px; color: #111; }
h1 { font-size: 20px; text-align: center; margin-bottom: 30px; }
.content { max-width: 500px; margin: 0 auto; }
.logo { width: 80px; display: block; margin: 0 auto 20px; }
.box { border: 2px solid #111; padding: 20px; margin: 20px 0; }
.signature { margin-top: 40px; font-size: 11px; color: #666; }
</style></head>
<body>
<div class="content">
{% if logo %}<img src="{{ logo }}" class="logo" alt="Logo">{% endif %}
<h1>Attestation de suppression de donnees</h1>
<div class="box">
<p>Conformement a votre demande et en application de l'<strong>article 17 du Reglement General sur la Protection des Donnees (RGPD)</strong>, nous attestons que :</p>
<p style="margin: 15px 0;">Les donnees personnelles associees a l'adresse IP <strong>{{ ip }}</strong> ont ete <strong>supprimees</strong> de nos systemes le <strong>{{ date|date('d/m/Y a H:i') }}</strong>.</p>
<p>Cette suppression concerne l'ensemble des donnees de navigation collectees par notre systeme d'analyse d'audience, incluant :</p>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>Identifiants de session</li>
<li>Pages visitees</li>
<li>Donnees techniques (appareil, navigateur, systeme d'exploitation)</li>
<li>Liens eventuels avec un compte utilisateur</li>
</ul>
</div>
<div class="signature">
<p><strong>Association E-Cosplay</strong></p>
<p>SIREN : 943121517 / RNA : W022006988</p>
<p>42 rue de Saint-Quentin, 02800 Beautor, France</p>
<p>contact@e-cosplay.fr</p>
<p style="margin-top: 10px;">Document genere automatiquement — Reference : RGPD-DEL-{{ date|date('YmdHis') }}</p>
</div>
</div>
</body>
</html>