feat: gestion événements — historique, export ZIP, filtre galerie
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

This commit is contained in:
2026-07-17 14:43:05 +02:00
parent 7d60eba86d
commit d5f111d60b
10 changed files with 329 additions and 117 deletions
+9
View File
@@ -120,6 +120,15 @@ async def admin_settings(request: Request):
})
@router.get("/admin/events", response_class=HTMLResponse)
async def admin_events(request: Request):
redirect = _require_auth(request)
if redirect:
return redirect
cfg = request.app.state.config
return _templates.TemplateResponse(request, "admin/events.html", {"config": cfg})
@router.get("/admin/print", response_class=HTMLResponse)
async def admin_print(request: Request):
redirect = _require_auth(request)
+53
View File
@@ -2,10 +2,14 @@
from __future__ import annotations
import io
import logging
import zipfile
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel
from backend.services.event_service import slugify
@@ -81,6 +85,55 @@ async def update_event(request: Request, payload: EventUpdate):
}
@router.get("/event/{slug}/export")
async def export_event_zip(request: Request, slug: str):
"""Exporte toutes les photos d'un événement dans un fichier ZIP.
Les photos sont sélectionnées par timestamp de fichier (mtime) entre
started_at et ended_at de l'événement. Pour l'événement en cours,
ended_at = maintenant.
"""
event_svc = request.app.state.event_service
cfg = request.app.state.config
ev = await event_svc.get_event_by_slug(slug)
if not ev:
return JSONResponse({"error": f"Événement '{slug}' introuvable"}, status_code=404)
started_at = ev["started_at"] or 0.0
ended_at = ev["ended_at"] or datetime.now().timestamp()
media_dir = Path(cfg.photobooth.media_dir)
if not media_dir.exists():
return JSONResponse({"error": "Dossier média introuvable"}, status_code=500)
# Sélectionner les photos dans la plage de l'événement
photos = []
for f in sorted(media_dir.iterdir()):
if f.suffix.lower() not in (".jpg", ".jpeg", ".png"):
continue
mtime = f.stat().st_mtime
if started_at <= mtime <= ended_at:
photos.append(f)
if not photos:
return JSONResponse({"error": "Aucune photo pour cet événement"}, status_code=404)
# Créer le ZIP en mémoire
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for photo in photos:
zf.write(photo, photo.name)
buf.seek(0)
filename = f"{slug}_By_LSDW_{datetime.fromtimestamp(started_at).strftime('%Y-%m-%d')}.zip"
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/event/history")
async def get_event_history(request: Request):
"""Retourne tous les événements passés avec leurs statistiques."""
+26
View File
@@ -34,12 +34,37 @@ async def api_gallery_photos(
request: Request,
page: int = Query(default=1, ge=1),
limit: int = Query(default=24, ge=1, le=100),
event_slug: str = Query(default=None, description="Filtrer par slug d'événement"),
):
pb = request.app.state.photobooth_service
cfg = request.app.state.config
all_photos = await pb.get_media_collection(limit=500)
photos = [p for p in all_photos if _is_image(p)]
# Filtre par événement si demandé
if event_slug:
from pathlib import Path
from datetime import datetime
event_svc = getattr(request.app.state, "event_service", None)
if event_svc:
ev = await event_svc.get_event_by_slug(event_slug)
if ev:
started_at = ev["started_at"] or 0.0
ended_at = ev["ended_at"] or datetime.now().timestamp()
media_dir = Path(cfg.photobooth.media_dir)
# Construire un index mtime par nom de fichier
mtime_index: dict[str, float] = {}
if media_dir.exists():
for f in media_dir.iterdir():
mtime_index[f.name] = f.stat().st_mtime
# Filtrer les photos par mtime
def _in_event(p: dict) -> bool:
pid = _get_id(p)
mtime = mtime_index.get(pid, mtime_index.get(pid + ".jpg", 0.0))
return started_at <= mtime <= ended_at
photos = [p for p in photos if _in_event(p)]
total = len(photos)
start = (page - 1) * limit
end = start + limit
@@ -56,6 +81,7 @@ async def api_gallery_photos(
"total": total,
"page": page,
"pages": max(1, (total + limit - 1) // limit),
"event_slug": event_slug,
}
+11
View File
@@ -97,6 +97,17 @@ class EventService:
)
await self._db.commit()
async def get_event_by_slug(self, slug: str) -> dict | None:
"""Retourne un événement complet par son slug."""
async with self._db.execute(
"""SELECT id, name, slug, started_at, ended_at,
photos_taken, print_requests, prints_done, downloads
FROM events WHERE slug = ?""",
(slug,),
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def get_stats(self, slug: str) -> dict:
"""Retourne les stats pour un slug donné."""
async with self._db.execute(
+1
View File
@@ -7,6 +7,7 @@
<a href="/admin/actions" class="active">Actions</a>
<a href="/admin/print">Impression</a>
<a href="/admin/settings">Réglages</a>
<a href="/admin/events">Événements</a>
<a href="/admin/logout">Déconnexion</a>
{% endblock %}
+2
View File
@@ -7,6 +7,7 @@
<a href="/admin/actions">Actions</a>
<a href="/admin/print">Impression</a>
<a href="/admin/settings">Réglages</a>
<a href="/admin/events">Événements</a>
<a href="/admin/logout">Déconnexion</a>
{% endblock %}
@@ -330,3 +331,4 @@ setInterval(loadQueue, 15000);
setInterval(loadEvent, 60000);
</script>
{% endblock %}
+222
View File
@@ -0,0 +1,222 @@
{% extends "base.html" %}
{% block title %}Événements — JH Photomaton{% endblock %}
{% block nav_links %}
<a href="/admin">Dashboard</a>
<a href="/admin/gallery">Galerie</a>
<a href="/admin/actions">Actions</a>
<a href="/admin/print">Impression</a>
<a href="/admin/settings">Réglages</a>
<a href="/admin/events" class="active">Événements</a>
<a href="/admin/logout">Déconnexion</a>
{% endblock %}
{% block head %}
<style>
.events-container {
max-width: 1000px;
margin: 1.5rem auto;
padding: 0 1rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.card-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--primary);
margin-bottom: 1.25rem;
}
.event-row {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 1rem;
align-items: center;
padding: .75rem 0;
border-bottom: 1px solid var(--border);
}
.event-row:last-child { border-bottom: none; }
.event-name { font-weight: 600; }
.event-meta { font-size: .78rem; color: var(--text-muted); margin-top: .2rem; }
.badge {
display: inline-block;
padding: .2rem .6rem;
border-radius: 20px;
font-size: .75rem;
font-weight: 600;
}
.badge-active { background: #1a3a1a; color: #4caf50; }
.badge-done { background: var(--surface-2, #222); color: var(--text-muted); }
.stats-row {
display: flex;
gap: 1.5rem;
font-size: .82rem;
color: var(--text-muted);
}
.stats-row span strong { color: var(--text); }
</style>
{% endblock %}
{% block content %}
<div class="events-container">
<h1 class="page-title">📅 Historique des événements</h1>
<!-- Événement en cours -->
<div class="card">
<div class="card-title">🟢 Événement en cours</div>
<div id="current-event-loading" style="color:var(--text-muted)">Chargement…</div>
<div id="current-event" style="display:none">
<div class="event-row">
<div>
<div class="event-name" id="cur-name"></div>
<div class="event-meta">Slug : <code id="cur-slug"></code> &nbsp;|&nbsp; Démarré le <span id="cur-started"></span></div>
<div class="stats-row" style="margin-top:.5rem">
<span>📷 <strong id="cur-photos">0</strong> photos</span>
<span>🖨️ <strong id="cur-prints">0</strong> impressions</span>
<span>⬇️ <strong id="cur-downloads">0</strong> téléchargements</span>
</div>
</div>
<button class="btn btn-ghost btn-sm" onclick="filterGallery(document.getElementById('cur-slug').textContent)">
🖼️ Voir photos
</button>
<a id="cur-export-btn" href="#" class="btn btn-primary btn-sm" download>
⬇️ Export ZIP
</a>
</div>
</div>
</div>
<!-- Filtre galerie -->
<div class="card" id="gallery-filter-card" style="display:none">
<div class="card-title">🖼️ Photos de l'événement : <span id="filter-event-name"></span></div>
<div id="gallery-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(120px,1fr)); gap:.5rem;"></div>
<div style="margin-top:.75rem; display:flex; gap:.5rem; align-items:center;">
<button class="btn btn-ghost btn-sm" onclick="closeGallery()">✕ Fermer</button>
<span id="gallery-count" style="font-size:.82rem; color:var(--text-muted)"></span>
</div>
</div>
<!-- Historique -->
<div class="card">
<div class="card-title">📋 Historique</div>
<div id="history-loading" style="color:var(--text-muted)">Chargement…</div>
<div id="history-list" style="display:none"></div>
</div>
</div>
<script>
async function api(method, url) {
const r = await fetch('/api' + url, { method });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
function showToast(msg, type='info') {
const t = document.createElement('div');
t.className = `toast toast-${type}`;
t.textContent = msg;
document.getElementById('toasts').appendChild(t);
setTimeout(() => t.remove(), 3000);
}
async function loadCurrentEvent() {
try {
const d = await api('GET', '/event');
document.getElementById('current-event-loading').style.display = 'none';
document.getElementById('current-event').style.display = 'block';
document.getElementById('cur-name').textContent = d.name;
document.getElementById('cur-slug').textContent = d.slug;
document.getElementById('cur-started').textContent = d.started_at_iso
? new Date(d.started_at * 1000).toLocaleString('fr-FR')
: '—';
document.getElementById('cur-photos').textContent = d.stats.photos_taken;
document.getElementById('cur-prints').textContent = d.stats.prints_done;
document.getElementById('cur-downloads').textContent = d.stats.downloads;
document.getElementById('cur-export-btn').href = `/api/event/${d.slug}/export`;
} catch(e) {
document.getElementById('current-event-loading').textContent = 'Erreur chargement';
}
}
async function loadHistory() {
try {
const d = await api('GET', '/event/history');
document.getElementById('history-loading').style.display = 'none';
const list = document.getElementById('history-list');
list.style.display = 'block';
if (!d.events.length) {
list.innerHTML = '<p style="color:var(--text-muted)">Aucun événement enregistré.</p>';
return;
}
list.innerHTML = d.events.map(ev => `
<div class="event-row">
<div>
<div class="event-name">
${ev.name}
<span class="badge ${ev.ended_at ? 'badge-done' : 'badge-active'}">
${ev.ended_at ? 'Terminé' : 'En cours'}
</span>
</div>
<div class="event-meta">
${ev.started_at_iso || '—'}
${ev.ended_at_iso ? ' → ' + ev.ended_at_iso : ''}
</div>
<div class="stats-row" style="margin-top:.35rem">
<span>📷 <strong>${ev.photos_taken}</strong></span>
<span>🖨️ <strong>${ev.prints_done}</strong></span>
<span>⬇️ <strong>${ev.downloads}</strong></span>
</div>
</div>
<button class="btn btn-ghost btn-sm" onclick="filterGallery('${ev.slug}', '${ev.name}')">
🖼️ Photos
</button>
<a href="/api/event/${ev.slug}/export" class="btn btn-primary btn-sm" download>
⬇️ ZIP
</a>
</div>
`).join('');
} catch(e) {
document.getElementById('history-loading').textContent = 'Erreur chargement';
}
}
async function filterGallery(slug, name) {
const card = document.getElementById('gallery-filter-card');
const grid = document.getElementById('gallery-grid');
const countEl = document.getElementById('gallery-count');
document.getElementById('filter-event-name').textContent = name || slug;
card.style.display = 'block';
grid.innerHTML = '<div style="color:var(--text-muted)">Chargement…</div>';
card.scrollIntoView({ behavior: 'smooth' });
try {
const d = await fetch(`/api/gallery/photos?event_slug=${slug}&limit=100`).then(r => r.json());
countEl.textContent = `${d.total} photo(s)`;
if (!d.photos.length) {
grid.innerHTML = '<p style="color:var(--text-muted)">Aucune photo pour cet événement.</p>';
return;
}
grid.innerHTML = d.photos.map(p => `
<a href="${p.full_url}" target="_blank">
<img src="${p.thumb_url}" alt="" style="width:100%;border-radius:6px;object-fit:cover;aspect-ratio:1">
</a>
`).join('');
} catch(e) {
grid.innerHTML = '<p style="color:var(--text-muted)">Erreur chargement photos.</p>';
}
}
function closeGallery() {
document.getElementById('gallery-filter-card').style.display = 'none';
}
loadCurrentEvent();
loadHistory();
</script>
{% endblock %}
+2
View File
@@ -7,6 +7,7 @@
<a href="/admin/actions">Actions</a>
<a href="/admin/print">Impression</a>
<a href="/admin/settings">Réglages</a>
<a href="/admin/events">Événements</a>
<a href="/admin/logout">Déconnexion</a>
{% endblock %}
@@ -514,3 +515,4 @@ setInterval(async () => {
loadPhotos(1);
</script>
{% endblock %}
+2
View File
@@ -7,6 +7,7 @@
<a href="/admin/actions">Actions</a>
<a href="/admin/print" class="active">Impression</a>
<a href="/admin/settings">Réglages</a>
<a href="/admin/events">Événements</a>
<a href="/admin/logout">Déconnexion</a>
{% endblock %}
@@ -401,3 +402,4 @@ setInterval(refreshQueue, 10000);
setInterval(refreshPrinters, 15000);
</script>
{% endblock %}
+1 -117
View File
@@ -801,120 +801,4 @@ async function loadBrightnessConfig() {
document.getElementById('toggle-auto-brightness').checked = auto;
document.getElementById('auto-brightness-status').textContent = auto
? '✅ Auto-flash actif — le flash s\'adapte à l\'ambiance'
: '⏸ Auto-flash désactivé — luminosité flash fixe à 100 %';
} catch(e) { console.warn('loadBrightnessConfig:', e); }
}
async function previewBrightness() {
const v = parseInt(document.getElementById('brightness-slider').value);
await api('PUT', `/api/leds/brightness?value=${v}&save=false`);
showToast(`Luminosité ${v}/255 appliquée`, 'info');
}
async function saveBrightness() {
const v = parseInt(document.getElementById('brightness-slider').value);
const status = document.getElementById('brightness-status');
try {
await api('PUT', `/api/leds/brightness?value=${v}&save=true`);
status.textContent = '✅ Sauvegardé';
showToast(`Luminosité ${v}/255 sauvegardée`, 'success');
} catch(e) {
status.textContent = '❌ Erreur';
showToast('Erreur : ' + e.message, 'error');
}
setTimeout(() => { status.textContent = ''; }, 3000);
}
async function setAutoBrightness(enabled) {
const statusEl = document.getElementById('auto-brightness-status');
try {
await api('PUT', `/api/leds/auto-brightness?enabled=${enabled}&save=true`);
statusEl.textContent = enabled
? '✅ Auto-flash actif — le flash s\'adapte à l\'ambiance'
: '⏸ Auto-flash désactivé — luminosité flash fixe à 100 %';
showToast(enabled ? '✅ Auto-luminosité activée' : '⏸ Auto-luminosité désactivée', 'success');
} catch(e) {
showToast('Erreur : ' + e.message, 'error');
document.getElementById('toggle-auto-brightness').checked = !enabled;
}
}
loadBrightnessConfig();
// ════════════════════════════════════════════════════════════════════════════
// Flash LED
// ════════════════════════════════════════════════════════════════════════════
async function loadFlashConfig() {
try {
const effects = await api('GET', '/api/leds/effects');
const cap = effects.capture;
if (!cap) return;
document.getElementById('flash-r').value = cap.color[0] ?? 255;
document.getElementById('flash-g').value = cap.color[1] ?? 200;
document.getElementById('flash-b').value = cap.color[2] ?? 80;
document.getElementById('flash-duration').value = cap.flash_duration ?? 0.30;
document.getElementById('flash-dur-val').textContent = parseFloat(cap.flash_duration ?? 0.30).toFixed(2);
document.getElementById('flash-count').value = cap.flashes ?? 2;
updateFlashPreview();
} catch(e) { console.warn('loadFlashConfig:', e); }
}
function updateFlashPreview() {
const r = parseInt(document.getElementById('flash-r').value) || 0;
const g = parseInt(document.getElementById('flash-g').value) || 0;
const b = parseInt(document.getElementById('flash-b').value) || 0;
document.getElementById('flash-preview').style.background = `rgb(${r},${g},${b})`;
['flash-r','flash-g','flash-b'].forEach(id =>
document.getElementById(id).addEventListener('input', updateFlashPreview, {once:true})
);
}
function colorPickerToRgb(hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
document.getElementById('flash-r').value = r;
document.getElementById('flash-g').value = g;
document.getElementById('flash-b').value = b;
updateFlashPreview();
}
function setFlashPreset(r, g, b, dur, flashes) {
document.getElementById('flash-r').value = r;
document.getElementById('flash-g').value = g;
document.getElementById('flash-b').value = b;
document.getElementById('flash-duration').value = dur;
document.getElementById('flash-dur-val').textContent = dur.toFixed(2);
document.getElementById('flash-count').value = flashes;
updateFlashPreview();
}
async function previewFlash() {
await api('POST', '/api/leds/effect?effect=capture');
showToast('Flash test declenche', 'info');
}
async function saveFlash() {
const r = parseInt(document.getElementById('flash-r').value);
const g = parseInt(document.getElementById('flash-g').value);
const b = parseInt(document.getElementById('flash-b').value);
const dur = parseFloat(document.getElementById('flash-duration').value);
const flashes = parseInt(document.getElementById('flash-count').value);
try {
await api('PUT', `/api/leds/effect/capture?r=${r}&g=${g}&b=${b}&flash_duration=${dur}&flashes=${flashes}&save=true`);
showToast('Flash sauvegarde', 'success');
updateFlashPreview();
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
}
// Liaison input -> preview
['flash-r','flash-g','flash-b'].forEach(id => {
document.getElementById(id).addEventListener('input', updateFlashPreview);
});
loadFlashConfig();
// Init dots
showDots(0);
</script>
{% endblock %}
: '⏸ Auto-flash désactivé — lumi