diff --git a/backend/api/admin_api.py b/backend/api/admin_api.py index 015b76f..4259fb4 100644 --- a/backend/api/admin_api.py +++ b/backend/api/admin_api.py @@ -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) diff --git a/backend/api/event_api.py b/backend/api/event_api.py index 4810981..7186e55 100644 --- a/backend/api/event_api.py +++ b/backend/api/event_api.py @@ -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.""" diff --git a/backend/api/gallery.py b/backend/api/gallery.py index 035eebf..2bea5da 100644 --- a/backend/api/gallery.py +++ b/backend/api/gallery.py @@ -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, } diff --git a/backend/services/event_service.py b/backend/services/event_service.py index 30bc5b9..69422a5 100644 --- a/backend/services/event_service.py +++ b/backend/services/event_service.py @@ -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( diff --git a/frontend/templates/admin/actions.html b/frontend/templates/admin/actions.html index 36de9a7..0af8258 100644 --- a/frontend/templates/admin/actions.html +++ b/frontend/templates/admin/actions.html @@ -7,6 +7,7 @@ Actions Impression Réglages +Événements Déconnexion {% endblock %} diff --git a/frontend/templates/admin/dashboard.html b/frontend/templates/admin/dashboard.html index 903576d..a599700 100644 --- a/frontend/templates/admin/dashboard.html +++ b/frontend/templates/admin/dashboard.html @@ -7,6 +7,7 @@ Actions Impression Réglages +Événements Déconnexion {% endblock %} @@ -330,3 +331,4 @@ setInterval(loadQueue, 15000); setInterval(loadEvent, 60000); {% endblock %} + \ No newline at end of file diff --git a/frontend/templates/admin/events.html b/frontend/templates/admin/events.html new file mode 100644 index 0000000..3910959 --- /dev/null +++ b/frontend/templates/admin/events.html @@ -0,0 +1,222 @@ +{% extends "base.html" %} +{% block title %}Événements — JH Photomaton{% endblock %} + +{% block nav_links %} +Dashboard +Galerie +Actions +Impression +Réglages +Événements +Déconnexion +{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+

📅 Historique des événements

+ + +
+
🟢 Événement en cours
+
Chargement…
+ +
+ + + + + +
+
📋 Historique
+
Chargement…
+ +
+
+ + +{% endblock %} diff --git a/frontend/templates/admin/gallery.html b/frontend/templates/admin/gallery.html index eb3081e..fb66d4a 100644 --- a/frontend/templates/admin/gallery.html +++ b/frontend/templates/admin/gallery.html @@ -7,6 +7,7 @@ Actions Impression Réglages +Événements Déconnexion {% endblock %} @@ -514,3 +515,4 @@ setInterval(async () => { loadPhotos(1); {% endblock %} + \ No newline at end of file diff --git a/frontend/templates/admin/print.html b/frontend/templates/admin/print.html index c8b977b..639b3f5 100644 --- a/frontend/templates/admin/print.html +++ b/frontend/templates/admin/print.html @@ -7,6 +7,7 @@ Actions Impression Réglages +Événements Déconnexion {% endblock %} @@ -401,3 +402,4 @@ setInterval(refreshQueue, 10000); setInterval(refreshPrinters, 15000); {% endblock %} + \ No newline at end of file diff --git a/frontend/templates/admin/settings.html b/frontend/templates/admin/settings.html index 91b12e8..615d519 100644 --- a/frontend/templates/admin/settings.html +++ b/frontend/templates/admin/settings.html @@ -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); - -{% endblock %} + : '⏸ Auto-flash désactivé — lumi \ No newline at end of file