diff --git a/backend/api/admin_api.py b/backend/api/admin_api.py
index 2aeada2..c56c4be 100644
--- a/backend/api/admin_api.py
+++ b/backend/api/admin_api.py
@@ -110,6 +110,19 @@ async def admin_actions(request: Request):
})
+@router.get("/admin/settings", response_class=HTMLResponse)
+async def admin_settings(request: Request):
+ redirect = _require_auth(request)
+ if redirect:
+ return redirect
+ cfg = request.app.state.config
+ return _templates.TemplateResponse("admin/settings.html", {
+ "request": request,
+ "config": cfg,
+ "button": cfg.button,
+ })
+
+
@router.get("/admin/print", response_class=HTMLResponse)
async def admin_print(request: Request):
redirect = _require_auth(request)
diff --git a/backend/api/admin_gallery_api.py b/backend/api/admin_gallery_api.py
index f112728..4c555ad 100644
--- a/backend/api/admin_gallery_api.py
+++ b/backend/api/admin_gallery_api.py
@@ -1,98 +1,15 @@
"""API galerie admin — impression et suppression de photos."""
import logging
+from pathlib import Path
from fastapi import APIRouter, Request, Query
-from fastapi.responses import JSONResponse, RedirectResponse
+from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
router = APIRouter()
-@router.get("/admin/api/gallery/photos")
-async def admin_get_photos(
- request: Request,
- page: int = Query(default=1, ge=1),
- limit: int = Query(default=24, ge=1, le=100),
-):
- """Liste des photos pour la galerie admin."""
- if not request.session.get("authenticated"):
- return JSONResponse({"error": "Non authentifié"}, status_code=401)
-
- pb = request.app.state.photobooth_service
- all_photos = await pb.get_media_collection(limit=500)
- photos = [p for p in all_photos if _is_image(p)]
-
- total = len(photos)
- start = (page - 1) * limit
- page_photos = photos[start:start + limit]
-
- for p in page_photos:
- pid = _get_id(p)
- p["full_url"] = pb.media_url(pid)
- p["thumb_url"] = pb.thumbnail_url(pid)
-
- return {"photos": page_photos, "total": total, "page": page,
- "pages": max(1, (total + limit - 1) // limit)}
-
-
-@router.post("/admin/api/gallery/print/{photo_id}")
-async def admin_print_photo(
- request: Request,
- photo_id: str,
- copies: int = Query(default=1, ge=1, le=3),
-):
- """Impression directe depuis la galerie admin."""
- if not request.session.get("authenticated"):
- return JSONResponse({"error": "Non authentifié"}, status_code=401)
-
- pb = request.app.state.photobooth_service
- printer_svc = request.app.state.printer_service
- led = request.app.state.led_service
- ws = request.app.state.ws_manager
-
- # Reconstruit le chemin du fichier depuis l'ID
- from pathlib import Path
- import re
- cfg = request.app.state.config
-
- # photobooth-app identifiant → chemin fichier
- media_dir = cfg.photobooth.media_dir
- # Cherche le fichier correspondant
- filename = _find_file(media_dir, photo_id)
- if not filename:
- return JSONResponse({"error": f"Fichier introuvable pour {photo_id}"}, status_code=404)
-
- thumb_url = pb.thumbnail_url(photo_id)
- entry = await printer_svc.add_request(str(filename), thumb_url, copies)
-
- if cfg.print.mode == "direct":
- led.play("printing")
- result = await printer_svc.execute_print(entry["id"], copies)
- await ws.broadcast({"type": "print_result", "result": result})
- if result["success"]:
- led.play("finished")
- else:
- led.play("error")
- return result
-
- await ws.broadcast({"type": "print_request", "entry": entry})
- return {"ok": True, "entry": entry, "mode": cfg.print.mode}
-
-
-@router.delete("/admin/api/gallery/{photo_id}")
-async def admin_delete_photo(request: Request, photo_id: str):
- """Supprime une photo via l'API photobooth-app."""
- if not request.session.get("authenticated"):
- return JSONResponse({"error": "Non authentifié"}, status_code=401)
-
- pb = request.app.state.photobooth_service
- ws = request.app.state.ws_manager
-
- ok = await pb.delete_media(photo_id)
- if ok:
- await ws.broadcast({"type": "photo_deleted", "photo_id": photo_id})
- return {"ok": ok, "photo_id": photo_id}
-
+# ── Helpers ───────────────────────────────────────────────────────────────────
def _is_image(item: dict) -> bool:
t = item.get("type", item.get("mediaitem_type", "image"))
@@ -103,20 +20,179 @@ def _get_id(item: dict) -> str:
return str(item.get("id", item.get("filename", item.get("uid", ""))))
-def _find_file(media_dir: str, photo_id: str):
+def _find_file(media_dir: str, photo_id: str) -> Path | None:
"""Cherche un fichier image correspondant à l'identifiant dans le répertoire media."""
- from pathlib import Path
base = Path(media_dir)
if not base.exists():
return None
-
- # L'ID peut être le stem du filename
for ext in (".jpg", ".jpeg", ".png"):
f = base / f"{photo_id}{ext}"
if f.exists():
return f
- # Cherche dans les sous-dossiers
matches = list(base.rglob(f"{photo_id}{ext}"))
if matches:
return matches[0]
return None
+
+
+def _require_auth(request: Request):
+ return request.session.get("authenticated") is True
+
+
+# ── Photos ────────────────────────────────────────────────────────────────────
+
+@router.get("/admin/api/gallery/photos")
+async def admin_get_photos(
+ request: Request,
+ page: int = Query(default=1, ge=1),
+ limit: int = Query(default=24, ge=1, le=100),
+):
+ """Liste des photos pour la galerie admin, annotées avec leurs demandes d'impression."""
+ if not _require_auth(request):
+ return JSONResponse({"error": "Non authentifié"}, status_code=401)
+
+ pb = request.app.state.photobooth_service
+ printer_svc = request.app.state.printer_service
+
+ # Récupère toutes les photos
+ all_photos = await pb.get_media_collection(limit=500)
+ photos = [p for p in all_photos if _is_image(p)]
+
+ total = len(photos)
+ start = (page - 1) * limit
+ page_photos = photos[start:start + limit]
+
+ # Construit les URLs
+ for p in page_photos:
+ pid = _get_id(p)
+ p["photo_id"] = pid
+ p["full_url"] = pb.media_url(pid)
+ p["thumb_url"] = pb.thumbnail_url(pid)
+
+ # Croise avec la file d'impression en attente (une seule requête SQLite)
+ try:
+ pending_map = await printer_svc.get_pending_by_photo_id()
+ for p in page_photos:
+ pid = p.get("photo_id", "")
+ requests = pending_map.get(pid, [])
+ p["print_requests"] = requests
+ p["print_pending"] = len([r for r in requests if r["status"] == "pending"])
+ p["print_printing"] = len([r for r in requests if r["status"] == "printing"])
+ except Exception as e:
+ logger.warning("Impossible de croiser avec print_queue: %s", e)
+ for p in page_photos:
+ p["print_requests"] = []
+ p["print_pending"] = 0
+ p["print_printing"] = 0
+
+ return {
+ "photos": page_photos,
+ "total": total,
+ "page": page,
+ "pages": max(1, (total + limit - 1) // limit),
+ }
+
+
+# ── Demandes d'impression ─────────────────────────────────────────────────────
+
+@router.post("/admin/api/gallery/print/{photo_id}")
+async def admin_print_photo(
+ request: Request,
+ photo_id: str,
+ copies: int = Query(default=1, ge=1, le=3),
+ immediate: bool = Query(default=False, description="True = imprimer maintenant sans passer par la file"),
+):
+ """Ajoute une demande d'impression (ou imprime immédiatement si immediate=true).
+
+ En mode 'validation', la demande est mise en file — l'admin valide via /admin/print.
+ En mode 'direct' ou si immediate=true, l'impression est lancée tout de suite.
+ """
+ if not _require_auth(request):
+ return JSONResponse({"error": "Non authentifié"}, status_code=401)
+
+ pb = request.app.state.photobooth_service
+ printer_svc = request.app.state.printer_service
+ led = request.app.state.led_service
+ ws = request.app.state.ws_manager
+ cfg = request.app.state.config
+
+ filename = _find_file(cfg.photobooth.media_dir, photo_id)
+ if not filename:
+ return JSONResponse({"error": f"Fichier introuvable pour {photo_id}"}, status_code=404)
+
+ thumb_url = pb.thumbnail_url(photo_id)
+ entry = await printer_svc.add_request(str(filename), thumb_url, copies)
+
+ should_print_now = (cfg.print.mode == "direct") or immediate
+
+ if should_print_now:
+ led.play("printing")
+ result = await printer_svc.execute_print(entry["id"], copies)
+ await ws.broadcast({"type": "print_result", "result": result, "photo_id": photo_id})
+ led.play("finished" if result["success"] else "error")
+ return {**result, "entry_id": entry["id"], "photo_id": photo_id}
+
+ # Mode validation : juste en file
+ await ws.broadcast({
+ "type": "print_request",
+ "entry": entry,
+ "photo_id": photo_id,
+ })
+ return {
+ "ok": True,
+ "queued": True,
+ "entry_id": entry["id"],
+ "photo_id": photo_id,
+ "mode": cfg.print.mode,
+ }
+
+
+@router.delete("/admin/api/gallery/print/{photo_id}")
+async def admin_cancel_print_requests(request: Request, photo_id: str):
+ """Annule toutes les demandes d'impression en attente pour une photo."""
+ if not _require_auth(request):
+ return JSONResponse({"error": "Non authentifié"}, status_code=401)
+
+ printer_svc = request.app.state.printer_service
+ ws = request.app.state.ws_manager
+
+ count = await printer_svc.cancel_by_photo_id(photo_id)
+ if count:
+ await ws.broadcast({"type": "print_cancelled_for_photo", "photo_id": photo_id, "count": count})
+ return {"ok": True, "photo_id": photo_id, "cancelled": count}
+
+
+@router.get("/admin/api/gallery/print-status")
+async def admin_print_status(request: Request):
+ """Retourne le dict {photo_id_stem: pending_count} pour toute la file.
+
+ Utilisé par la galerie pour mettre à jour les badges sans recharger les photos.
+ """
+ if not _require_auth(request):
+ return JSONResponse({"error": "Non authentifié"}, status_code=401)
+
+ printer_svc = request.app.state.printer_service
+ pending_map = await printer_svc.get_pending_by_photo_id()
+
+ # Format compact : {stem: count} pour minimiser la taille de la réponse
+ return {
+ stem: len(entries)
+ for stem, entries in pending_map.items()
+ }
+
+
+# ── Suppression ───────────────────────────────────────────────────────────────
+
+@router.delete("/admin/api/gallery/{photo_id}")
+async def admin_delete_photo(request: Request, photo_id: str):
+ """Supprime une photo via l'API photobooth-app."""
+ if not _require_auth(request):
+ return JSONResponse({"error": "Non authentifié"}, status_code=401)
+
+ pb = request.app.state.photobooth_service
+ ws = request.app.state.ws_manager
+
+ ok = await pb.delete_media(photo_id)
+ if ok:
+ await ws.broadcast({"type": "photo_deleted", "photo_id": photo_id})
+ return {"ok": ok, "photo_id": photo_id}
diff --git a/backend/api/event_api.py b/backend/api/event_api.py
new file mode 100644
index 0000000..4810981
--- /dev/null
+++ b/backend/api/event_api.py
@@ -0,0 +1,99 @@
+"""API de gestion des événements et statistiques."""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+
+from fastapi import APIRouter, Request
+from pydantic import BaseModel
+
+from backend.services.event_service import slugify
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+class EventUpdate(BaseModel):
+ name: str
+ new_event: bool = False # True = archive l'ancien et crée un nouveau
+
+
+@router.get("/event")
+async def get_current_event(request: Request):
+ """Retourne l'événement en cours avec ses statistiques."""
+ cfg = request.app.state.config
+ event_svc = request.app.state.event_service
+
+ ev = cfg.event
+ stats = await event_svc.get_stats(ev.slug)
+
+ return {
+ "name": ev.name,
+ "slug": ev.slug,
+ "started_at": ev.started_at,
+ "started_at_iso": (
+ datetime.fromtimestamp(ev.started_at).isoformat()
+ if ev.started_at
+ else None
+ ),
+ "stats": stats,
+ }
+
+
+@router.put("/event")
+async def update_event(request: Request, payload: EventUpdate):
+ """Met à jour l'événement en cours.
+
+ Si new_event=True : archive le précédent et démarre un nouvel événement
+ (les stats repartent de zéro).
+ Si new_event=False : renomme l'événement en cours (même slug ou nouveau slug,
+ les stats existantes restent sous l'ancien slug).
+ """
+ cfg = request.app.state.config
+ config_svc = request.app.state.config_service
+ event_svc = request.app.state.event_service
+
+ new_slug = slugify(payload.name)
+ now = datetime.now().timestamp()
+
+ old_slug = cfg.event.slug
+
+ if payload.new_event and old_slug and old_slug != new_slug:
+ # Archive l'ancien événement
+ await event_svc.archive_event(old_slug)
+
+ # Crée ou met à jour la ligne dans la DB
+ await event_svc.ensure_event(new_slug, payload.name, now if payload.new_event else cfg.event.started_at or now)
+
+ # Persiste dans settings.yaml
+ started = now if payload.new_event or not cfg.event.started_at else cfg.event.started_at
+ config_svc.save_event_config(payload.name, new_slug, started)
+
+ stats = await event_svc.get_stats(new_slug)
+ return {
+ "ok": True,
+ "name": payload.name,
+ "slug": new_slug,
+ "started_at": started,
+ "new_event": payload.new_event,
+ "stats": stats,
+ }
+
+
+@router.get("/event/history")
+async def get_event_history(request: Request):
+ """Retourne tous les événements passés avec leurs statistiques."""
+ event_svc = request.app.state.event_service
+ history = await event_svc.get_history()
+
+ # Ajoute les dates ISO pour l'affichage
+ for ev in history:
+ if ev.get("started_at"):
+ ev["started_at_iso"] = datetime.fromtimestamp(ev["started_at"]).strftime("%d/%m/%Y %H:%M")
+ if ev.get("ended_at"):
+ ev["ended_at_iso"] = datetime.fromtimestamp(ev["ended_at"]).strftime("%d/%m/%Y %H:%M")
+ else:
+ ev["ended_at_iso"] = None
+
+ return {"events": history}
diff --git a/backend/api/gallery.py b/backend/api/gallery.py
index 2713c8f..107abeb 100644
--- a/backend/api/gallery.py
+++ b/backend/api/gallery.py
@@ -1,12 +1,15 @@
-"""Galerie publique — accessible sans authentification."""
+"""Galerie publique -- accessible sans authentification."""
+import asyncio
import logging
-
-from fastapi import APIRouter, Request, Query
-from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
-from fastapi.templating import Jinja2Templates
+from datetime import datetime
from pathlib import Path
+import httpx
+from fastapi import APIRouter, Request, Query
+from fastapi.responses import HTMLResponse, RedirectResponse, Response
+from fastapi.templating import Jinja2Templates
+
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -15,7 +18,6 @@ _templates = Jinja2Templates(directory=Path(__file__).parent.parent.parent / "fr
@router.get("/", response_class=HTMLResponse)
async def index(request: Request):
- """Page d'accueil → redirige vers la galerie publique."""
return RedirectResponse(url="/gallery")
@@ -23,7 +25,7 @@ async def index(request: Request):
async def gallery_page(request: Request):
cfg = request.app.state.config
if not cfg.gallery.public_enabled:
- return HTMLResponse("
Galerie désactivée
", status_code=403)
+ return HTMLResponse("Galerie desactivee
", status_code=403)
return _templates.TemplateResponse("public/gallery.html", {"request": request, "config": cfg})
@@ -33,13 +35,9 @@ async def api_gallery_photos(
page: int = Query(default=1, ge=1),
limit: int = Query(default=24, ge=1, le=100),
):
- """Liste des photos depuis photobooth-app (paginée)."""
pb = request.app.state.photobooth_service
- cfg = request.app.state.config
all_photos = await pb.get_media_collection(limit=500)
-
- # Filtre sur les images uniquement
photos = [p for p in all_photos if _is_image(p)]
total = len(photos)
@@ -47,7 +45,6 @@ async def api_gallery_photos(
end = start + limit
page_photos = photos[start:end]
- # Enrichit avec les URLs
for p in page_photos:
pid = _get_id(p)
p["full_url"] = pb.media_url(pid)
@@ -64,9 +61,37 @@ async def api_gallery_photos(
@router.get("/api/gallery/download/{photo_id}")
async def download_photo(request: Request, photo_id: str):
- """Redirige vers le fichier full-res sur photobooth-app."""
+ """Proxy la photo full-res avec un nom de fichier lie a l'evenement en cours."""
pb = request.app.state.photobooth_service
- return RedirectResponse(url=pb.media_url(photo_id))
+ cfg = request.app.state.config
+ event_svc = getattr(request.app.state, "event_service", None)
+
+ img_url = pb.media_url(photo_id)
+ slug = cfg.event.slug or "photomaton"
+ date_str = datetime.now().strftime("%Y-%m-%d")
+ filename = f"{slug}_By_LSDW_{date_str}.jpg"
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(img_url)
+ r.raise_for_status()
+
+ if event_svc:
+ asyncio.create_task(event_svc.increment(slug, "downloads"))
+
+ content_type = r.headers.get("content-type", "image/jpeg")
+ return Response(
+ content=r.content,
+ media_type=content_type,
+ headers={
+ "Content-Disposition": f'attachment; filename="{filename}"',
+ "Content-Length": str(len(r.content)),
+ "Cache-Control": "no-cache",
+ },
+ )
+ except Exception as e:
+ logger.warning("Download proxy echoue (%s), fallback redirect: %s", photo_id, e)
+ return RedirectResponse(url=img_url)
def _is_image(item: dict) -> bool:
diff --git a/backend/api/leds_api.py b/backend/api/leds_api.py
index be9d361..7223b41 100644
--- a/backend/api/leds_api.py
+++ b/backend/api/leds_api.py
@@ -49,3 +49,52 @@ async def leds_off(request: Request):
led = request.app.state.led_service
led.play("off")
return {"ok": True}
+
+
+@router.get("/leds/effects")
+async def get_effects(request: Request):
+ """Retourne la configuration de tous les effets LED."""
+ cfg = request.app.state.config.leds
+ return {
+ name: {
+ "color": effect.get("color", [0, 0, 0]),
+ "flash_duration": effect.get("flash_duration", 0.1),
+ "flashes": effect.get("flashes", 2),
+ "speed": effect.get("speed", 0.05),
+ "duration": effect.get("duration", 2.0),
+ }
+ for name, effect in cfg.effects.items()
+ }
+
+
+@router.put("/leds/effect/{effect_name}")
+async def update_effect(
+ request: Request,
+ effect_name: str,
+ r: int = Query(default=None, ge=0, le=255),
+ g: int = Query(default=None, ge=0, le=255),
+ b: int = Query(default=None, ge=0, le=255),
+ flash_duration: float = Query(default=None, ge=0.05, le=2.0),
+ flashes: int = Query(default=None, ge=1, le=5),
+ save: bool = Query(default=True),
+):
+ """Met a jour la configuration d'un effet LED (couleur, timings).
+
+ Exemple : PUT /api/leds/effect/capture?r=255&g=200&b=80&flash_duration=0.3&flashes=2
+ """
+ cfg = request.app.state.config.leds
+ config_svc = request.app.state.config_service
+
+ effect_raw = cfg.effects.setdefault(effect_name, {})
+
+ if r is not None and g is not None and b is not None:
+ effect_raw["color"] = [r, g, b]
+ if flash_duration is not None:
+ effect_raw["flash_duration"] = flash_duration
+ if flashes is not None:
+ effect_raw["flashes"] = flashes
+
+ if save:
+ config_svc.save_led_effect(effect_name, effect_raw)
+
+ return {"ok": True, "effect": effect_name, "config": effect_raw}
diff --git a/backend/api/print_api.py b/backend/api/print_api.py
index e0ae088..fe79da4 100644
--- a/backend/api/print_api.py
+++ b/backend/api/print_api.py
@@ -1,5 +1,6 @@
"""API gestion de la file d'attente d'impression."""
+import asyncio
import logging
from pathlib import Path
@@ -33,6 +34,12 @@ async def print_request(
await ws.broadcast({"type": "print_request", "entry": entry})
+ # Compteur statistiques événement
+ event_svc = getattr(request.app.state, "event_service", None)
+ if event_svc:
+ slug = request.app.state.config.event.slug
+ asyncio.create_task(event_svc.increment(slug, "print_requests"))
+
# Feedback LED si impression directe
if request.app.state.config.print.mode == "direct":
led.play("printing")
@@ -71,6 +78,10 @@ async def execute_print(
if result["success"]:
led.play("finished")
+ event_svc = getattr(request.app.state, "event_service", None)
+ if event_svc:
+ slug = request.app.state.config.event.slug
+ asyncio.create_task(event_svc.increment(slug, "prints_done"))
else:
led.play("error")
@@ -112,3 +123,40 @@ async def set_print_mode(request: Request, mode: str = Query(...)):
config_svc.save_print_mode(mode)
request.app.state.config.print.mode = mode
return {"ok": True, "mode": mode}
+
+
+# ── Gestion avancée des imprimantes CUPS ──────────────────────────────────────
+
+@router.post("/print/printers/{printer_name}/enable")
+async def printer_enable(request: Request, printer_name: str):
+ """Active une imprimante CUPS (cupsenable + cupsaccept)."""
+ ok = await request.app.state.printer_service.enable_printer(printer_name)
+ return {"ok": ok, "printer": printer_name, "action": "enable"}
+
+
+@router.post("/print/printers/{printer_name}/disable")
+async def printer_disable(request: Request, printer_name: str):
+ """Désactive une imprimante CUPS (cupsdisable)."""
+ ok = await request.app.state.printer_service.disable_printer(printer_name)
+ return {"ok": ok, "printer": printer_name, "action": "disable"}
+
+
+@router.post("/print/printers/{printer_name}/reject")
+async def printer_reject(request: Request, printer_name: str):
+ """Refuse les nouveaux jobs sans stopper l'impression en cours."""
+ ok = await request.app.state.printer_service.reject_jobs(printer_name)
+ return {"ok": ok, "printer": printer_name, "action": "reject"}
+
+
+@router.get("/print/printers/{printer_name}/jobs")
+async def printer_jobs(request: Request, printer_name: str):
+ """Liste les jobs CUPS en cours pour une imprimante."""
+ jobs = await request.app.state.printer_service.get_cups_jobs(printer_name)
+ return {"printer": printer_name, "jobs": jobs}
+
+
+@router.delete("/print/printers/jobs/{job_id}")
+async def cancel_one_job(request: Request, job_id: str):
+ """Annule un job CUPS précis par son ID."""
+ ok = await request.app.state.printer_service.cancel_cups_job(job_id)
+ return {"ok": ok, "job_id": job_id}
diff --git a/backend/api/system_api.py b/backend/api/system_api.py
index f98df58..5e2f36c 100644
--- a/backend/api/system_api.py
+++ b/backend/api/system_api.py
@@ -1,8 +1,10 @@
"""API de surveillance des ressources système."""
import logging
-from fastapi import APIRouter, Request
+from typing import Optional
+from fastapi import APIRouter, Request, Body
from fastapi.responses import JSONResponse
+from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -50,3 +52,111 @@ async def control_relay(request: Request, state: str = "on"):
else:
btn.relay_off()
return {"ok": True, "relay": state}
+
+
+@router.get("/system/ui/delete-button")
+async def get_delete_button(request: Request):
+ """Retourne l'état actuel du bouton Supprimer de photobooth-app."""
+ visible = request.app.state.config.photobooth.show_delete_button
+ return {"visible": visible}
+
+
+@router.post("/system/ui/delete-button")
+async def set_delete_button(request: Request, visible: bool):
+ """Active ou désactive le bouton Supprimer dans l'UI de review photobooth-app.
+
+ Écrit userdata/private.css en temps réel — effectif à la prochaine capture,
+ sans redémarrage.
+ """
+ pb = request.app.state.photobooth_service
+ config_svc = request.app.state.config_service
+
+ ok = await pb.set_delete_button_visible(visible)
+ if ok:
+ config_svc.save_show_delete_button(visible)
+
+ return {"ok": ok, "visible": visible}
+
+
+@router.post("/system/screen/refresh")
+async def screen_refresh(request: Request):
+ """Envoie F5 à l'écran HDMI connecté (Chromium kiosque Wayland/X11)."""
+ import subprocess, os, asyncio
+
+ # Cherche le display Wayland du user pi (UID 1000)
+ # Essaie wayland-0 puis wayland-1
+ xdg_runtime = "/run/user/1000"
+ cmd_wtype = f"WAYLAND_DISPLAY=wayland-1 XDG_RUNTIME_DIR={xdg_runtime} wtype -k F5"
+ cmd_x11 = f"DISPLAY=:0 XAUTHORITY=/home/pi/.Xauthority xdotool key F5"
+
+ for cmd in [cmd_wtype, cmd_x11]:
+ try:
+ proc = await asyncio.create_subprocess_shell(
+ f"su -s /bin/bash pi -c '{cmd}'",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=3)
+ if proc.returncode == 0:
+ logger.info("Screen refresh OK via: %s", cmd.split()[0])
+ return {"ok": True, "method": cmd.split()[0]}
+ except Exception as e:
+ logger.debug("Screen refresh cmd failed (%s): %s", cmd.split()[0], e)
+
+ return JSONResponse({"ok": False, "error": "Aucune méthode disponible (wtype/xdotool)"}, status_code=500)
+
+
+class ButtonConfigUpdate(BaseModel):
+ double_click_ms: Optional[int] = None
+ long_press_ms: Optional[int] = None
+ debounce_ms: Optional[int] = None
+ max_clicks: Optional[int] = None
+ print_enabled: Optional[bool] = None
+ save: bool = False # True = persiste dans settings.yaml
+
+
+@router.get("/system/button/config")
+async def get_button_config(request: Request):
+ """Retourne la configuration actuelle du bouton (timings)."""
+ cfg = request.app.state.config.button
+ return {
+ "double_click_ms": cfg.double_click_ms,
+ "long_press_ms": cfg.long_press_ms,
+ "debounce_ms": cfg.debounce_ms,
+ "max_clicks": cfg.max_clicks,
+ "print_enabled": cfg.print_enabled,
+ }
+
+
+@router.put("/system/button/config")
+async def update_button_config(request: Request, payload: ButtonConfigUpdate):
+ """Met à jour les timings du bouton (à chaud + optionnellement sauvegardé)."""
+ btn = request.app.state.button_service
+ config_svc = request.app.state.config_service
+
+ update = {k: v for k, v in payload.model_dump(exclude={"save"}).items() if v is not None}
+
+ # Application immédiate (sans redémarrage)
+ btn.update_timings(
+ double_click_ms=payload.double_click_ms,
+ long_press_ms=payload.long_press_ms,
+ debounce_ms=payload.debounce_ms,
+ max_clicks=payload.max_clicks,
+ )
+
+ # Persistance optionnelle
+ if payload.save and update:
+ config_svc.save_button_config(update)
+
+ cfg = request.app.state.config.button
+ return {
+ "ok": True,
+ "saved": payload.save,
+ "current": {
+ "double_click_ms": cfg.double_click_ms,
+ "long_press_ms": cfg.long_press_ms,
+ "debounce_ms": cfg.debounce_ms,
+ "max_clicks": cfg.max_clicks,
+ "print_enabled": cfg.print_enabled,
+ }
+ }
diff --git a/backend/api/webhooks.py b/backend/api/webhooks.py
index 5d6dda4..a849d31 100644
--- a/backend/api/webhooks.py
+++ b/backend/api/webhooks.py
@@ -1,16 +1,6 @@
-"""Webhooks reçus de photobooth-app via plugin_commander.
-
-photobooth-app envoie un GET sur /api/webhook/photobooth?event_key=XXX&mediaitem_type=image
-pour chaque événement de capture.
-
-Événements :
- counting → countdown en cours (LEDs remplissage vert)
- capture → flash photo (LEDs flash blanc)
- captured → photo prise (LEDs violet)
- finished → traitement terminé (LEDs bleu → retour idle + relay ON)
- start/stop → démarrage/arrêt photobooth-app
-"""
+"""Webhooks recus de photobooth-app via plugin_commander."""
+import asyncio
import logging
from fastapi import APIRouter, Request, Query
@@ -18,7 +8,6 @@ from fastapi import APIRouter, Request, Query
logger = logging.getLogger(__name__)
router = APIRouter()
-# Durée par défaut du countdown (en secondes) — peut être overridé par action
DEFAULT_COUNTDOWN = 5.0
@@ -33,30 +22,25 @@ async def photobooth_webhook(
ws = request.app.state.ws_manager
logger.info("Webhook photobooth: event=%s type=%s", event_key, mediaitem_type)
-
await ws.broadcast({"type": "photobooth_event", "event": event_key, "media_type": mediaitem_type})
match event_key:
case "counting":
- # Countdown en cours — remplissage LED vert
- # On cherche la durée du countdown dans la config de l'action courante
- countdown_duration = _get_countdown_duration(request)
- led.play("countdown", countdown_duration=countdown_duration)
+ led.play("countdown", countdown_duration=DEFAULT_COUNTDOWN)
case "capture":
- # Flash photo
led.play("capture")
case "captured":
- # Photo prise, traitement en cours
led.play("captured")
case "finished":
- # Traitement terminé
led.play("finished")
- # Réactive le relay après l'animation "finished" (durée ~2s gérée par le service LED)
- import asyncio
asyncio.create_task(_delayed_relay_on(btn, 2.5))
+ event_svc = getattr(request.app.state, "event_service", None)
+ if event_svc:
+ slug = request.app.state.config.event.slug
+ asyncio.create_task(event_svc.increment(slug, "photos_taken"))
case "start":
led.play("idle")
@@ -65,28 +49,12 @@ async def photobooth_webhook(
led.play("off")
case _:
- logger.debug("Événement inconnu: %s", event_key)
+ logger.debug("Evenement inconnu: %s", event_key)
return {"ok": True, "event": event_key}
async def _delayed_relay_on(btn, delay: float):
- import asyncio
await asyncio.sleep(delay)
if btn:
btn.relay_on()
-
-
-def _get_countdown_duration(request: Request) -> float:
- """
- Essaie de récupérer la durée du countdown depuis la config photobooth-app
- en lisant le dernier index d'action déclenché par le bouton.
- Retourne la valeur par défaut si indisponible.
- """
- try:
- btn = request.app.state.button_service
- # On pourrait stocker le dernier index dans le button_service
- # Pour l'instant on retourne 5s par défaut
- return DEFAULT_COUNTDOWN
- except Exception:
- return DEFAULT_COUNTDOWN
diff --git a/backend/services/button_service.py b/backend/services/button_service.py
index 729c216..3333b11 100644
--- a/backend/services/button_service.py
+++ b/backend/services/button_service.py
@@ -42,6 +42,7 @@ class ButtonService:
self._click_count = 0
self._press_time: float = 0.0
self._click_timer: threading.Timer | None = None
+ self._long_timer: threading.Timer | None = None # ← fix: init explicite
self._long_press_fired = False
self._relay_enabled = True
self._lock = threading.Lock()
@@ -110,6 +111,33 @@ class ButtonService:
def simulate_long_press(self):
asyncio.run_coroutine_threadsafe(self._handle_long_press(), self._loop)
+ def update_timings(
+ self,
+ double_click_ms: int | None = None,
+ long_press_ms: int | None = None,
+ debounce_ms: int | None = None,
+ max_clicks: int | None = None,
+ ):
+ """Met à jour les timings à chaud (sans redémarrage du service)."""
+ if double_click_ms is not None:
+ self._cfg.double_click_ms = double_click_ms
+ if long_press_ms is not None:
+ self._cfg.long_press_ms = long_press_ms
+ if max_clicks is not None:
+ self._cfg.max_clicks = max(1, min(4, max_clicks))
+ if debounce_ms is not None:
+ self._cfg.debounce_ms = debounce_ms
+ if self._button:
+ try:
+ self._button.bounce_time = debounce_ms / 1000
+ except Exception:
+ pass
+ logger.info(
+ "Timings bouton mis à jour : double_click=%dms, long_press=%dms, debounce=%dms, max_clicks=%d",
+ self._cfg.double_click_ms, self._cfg.long_press_ms,
+ self._cfg.debounce_ms, self._cfg.max_clicks,
+ )
+
# ── Callbacks GPIO ────────────────────────────────────────────────────────
def _on_pressed(self):
diff --git a/backend/services/config_service.py b/backend/services/config_service.py
index c3941f2..b250602 100644
--- a/backend/services/config_service.py
+++ b/backend/services/config_service.py
@@ -28,6 +28,7 @@ class PhotoboothConfig:
config_file: str = "/home/pi/.config/photobooth-app/config.json"
media_dir: str = "/home/pi/photobooth-data/media/processed_full"
userdata_dir: str = "/home/pi/photobooth-data/userdata"
+ show_delete_button: bool = False
@dataclass
@@ -89,6 +90,13 @@ class GalleryConfig:
qr_base_url: str = "https://photomaton.lessapinsduweb.com"
+@dataclass
+class EventConfig:
+ name: str = "Evenement"
+ slug: str = "evenement"
+ started_at: float = 0.0
+
+
@dataclass
class Config:
app: AppConfig = field(default_factory=AppConfig)
@@ -97,6 +105,7 @@ class Config:
leds: LEDConfig = field(default_factory=LEDConfig)
print: PrintConfig = field(default_factory=PrintConfig)
gallery: GalleryConfig = field(default_factory=GalleryConfig)
+ event: EventConfig = field(default_factory=EventConfig)
button_actions: dict = field(default_factory=dict)
@@ -107,7 +116,7 @@ class ConfigService:
def load(self) -> Config:
if not self.path.exists():
- logger.warning(f"Config introuvable: {self.path} — utilisation des valeurs par défaut")
+ logger.warning("Config introuvable: %s", self.path)
self._config = Config()
return self._config
@@ -143,30 +152,60 @@ class ConfigService:
if "gallery" in raw:
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if hasattr(GalleryConfig, k)})
+ if "event" in raw:
+ cfg.event = EventConfig(**{k: v for k, v in raw["event"].items() if hasattr(EventConfig, k)})
+
cfg.button_actions = raw.get("button_actions", {
1: {"label": "Photo normale", "photobooth_index": 0},
- 2: {"label": "Photo étoile", "photobooth_index": 1},
+ 2: {"label": "Photo etoile", "photobooth_index": 1},
3: {"label": "Photo cailloux", "photobooth_index": 2},
- 4: {"label": "Photo soirée", "photobooth_index": 3},
+ 4: {"label": "Photo soiree", "photobooth_index": 3},
})
self._config = cfg
- logger.info("Configuration chargée depuis %s", self.path)
+ logger.info("Configuration chargee depuis %s", self.path)
return cfg
def save_button_actions(self, button_actions: dict):
- """Met à jour uniquement la section button_actions dans settings.yaml."""
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
-
raw["button_actions"] = button_actions
-
with open(self.path, "w", encoding="utf-8") as f:
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
-
if self._config:
self._config.button_actions = button_actions
+ def save_show_delete_button(self, visible: bool):
+ with open(self.path, encoding="utf-8") as f:
+ raw = yaml.safe_load(f) or {}
+ raw.setdefault("photobooth", {})["show_delete_button"] = visible
+ with open(self.path, "w", encoding="utf-8") as f:
+ yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
+ if self._config:
+ self._config.photobooth.show_delete_button = visible
+
+ def save_button_config(self, data: dict):
+ with open(self.path, encoding="utf-8") as f:
+ raw = yaml.safe_load(f) or {}
+ raw.setdefault("button", {}).update(data)
+ with open(self.path, "w", encoding="utf-8") as f:
+ yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
+ if self._config:
+ for k, v in data.items():
+ if hasattr(self._config.button, k):
+ setattr(self._config.button, k, v)
+
+ def save_event_config(self, name: str, slug: str, started_at: float):
+ with open(self.path, encoding="utf-8") as f:
+ raw = yaml.safe_load(f) or {}
+ raw["event"] = {"name": name, "slug": slug, "started_at": started_at}
+ with open(self.path, "w", encoding="utf-8") as f:
+ yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
+ if self._config:
+ self._config.event.name = name
+ self._config.event.slug = slug
+ self._config.event.started_at = started_at
+
def save_print_mode(self, mode: str):
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
@@ -181,3 +220,12 @@ class ConfigService:
if self._config is None:
self.load()
return self._config
+
+ def save_led_effect(self, effect_name: str, effect_data: dict):
+ with open(self.path, encoding="utf-8") as f:
+ raw = yaml.safe_load(f) or {}
+ raw.setdefault("leds", {}).setdefault("effects", {})[effect_name] = effect_data
+ with open(self.path, "w", encoding="utf-8") as f:
+ yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
+ if self._config:
+ self._config.leds.effects[effect_name] = effect_data
diff --git a/backend/services/event_service.py b/backend/services/event_service.py
new file mode 100644
index 0000000..30bc5b9
--- /dev/null
+++ b/backend/services/event_service.py
@@ -0,0 +1,125 @@
+"""Service de gestion des événements et statistiques.
+
+Chaque événement (soirée, mariage, fête...) a ses propres compteurs :
+ - photos_taken : incrémenté par le webhook 'finished' de photobooth-app
+ - print_requests : incrémenté à chaque demande d'impression dans la file
+ - prints_done : incrémenté à chaque impression réussie
+ - downloads : incrémenté à chaque téléchargement via /api/gallery/download/
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+import unicodedata
+from datetime import datetime
+from pathlib import Path
+
+import aiosqlite
+
+logger = logging.getLogger(__name__)
+
+
+def slugify(name: str) -> str:
+ """Transforme un nom d'événement en slug ASCII sans accents ni espaces."""
+ # Normalise les caractères Unicode (enlève les accents)
+ nfkd = unicodedata.normalize("NFD", name)
+ ascii_str = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
+ ascii_str = ascii_str.lower()
+ # Garde lettres, chiffres, espaces, tirets
+ ascii_str = re.sub(r"[^\w\s-]", "", ascii_str)
+ # Remplace espaces/tirets multiples par un underscore
+ ascii_str = re.sub(r"[\s_-]+", "_", ascii_str)
+ return ascii_str.strip("_") or "evenement"
+
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ slug TEXT NOT NULL UNIQUE,
+ started_at REAL NOT NULL,
+ ended_at REAL,
+ photos_taken INTEGER NOT NULL DEFAULT 0,
+ print_requests INTEGER NOT NULL DEFAULT 0,
+ prints_done INTEGER NOT NULL DEFAULT 0,
+ downloads INTEGER NOT NULL DEFAULT 0
+);
+"""
+
+_VALID_COUNTERS = {"photos_taken", "print_requests", "prints_done", "downloads"}
+
+
+class EventService:
+ def __init__(self):
+ self._db: aiosqlite.Connection | None = None
+
+ async def init_db(self, db_path: Path):
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._db = await aiosqlite.connect(str(db_path))
+ self._db.row_factory = aiosqlite.Row
+ await self._db.execute(_SCHEMA)
+ await self._db.commit()
+ logger.info("EventService initialisé : %s", db_path)
+
+ async def ensure_event(self, slug: str, name: str, started_at: float):
+ """Crée la ligne pour cet événement si elle n'existe pas encore."""
+ await self._db.execute(
+ """INSERT INTO events (name, slug, started_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(slug) DO UPDATE SET name = excluded.name""",
+ (name, slug, started_at),
+ )
+ await self._db.commit()
+
+ async def archive_event(self, slug: str):
+ """Marque un événement comme terminé (ended_at = maintenant)."""
+ await self._db.execute(
+ "UPDATE events SET ended_at = ? WHERE slug = ? AND ended_at IS NULL",
+ (datetime.now().timestamp(), slug),
+ )
+ await self._db.commit()
+
+ async def increment(self, slug: str, counter: str):
+ """Incrémente un compteur de l'événement identifié par son slug.
+
+ Crée automatiquement une ligne si elle n'existe pas.
+ """
+ if counter not in _VALID_COUNTERS:
+ logger.warning("Compteur inconnu : %s", counter)
+ return
+ # Upsert : insère si absent, sinon incrémente
+ await self._db.execute(
+ f"""INSERT INTO events (name, slug, started_at, {counter})
+ VALUES (?, ?, unixepoch(), 1)
+ ON CONFLICT(slug) DO UPDATE SET {counter} = {counter} + 1""",
+ (slug, slug),
+ )
+ await self._db.commit()
+
+ async def get_stats(self, slug: str) -> dict:
+ """Retourne les stats pour un slug donné."""
+ async with self._db.execute(
+ """SELECT photos_taken, print_requests, prints_done, downloads
+ FROM events WHERE slug = ?""",
+ (slug,),
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ return {"photos_taken": 0, "print_requests": 0, "prints_done": 0, "downloads": 0}
+ return dict(row)
+
+ async def get_history(self) -> list[dict]:
+ """Retourne tous les événements triés du plus récent au plus ancien."""
+ async with self._db.execute(
+ """SELECT id, name, slug, started_at, ended_at,
+ photos_taken, print_requests, prints_done, downloads
+ FROM events
+ ORDER BY started_at DESC"""
+ ) as cur:
+ rows = await cur.fetchall()
+ return [dict(r) for r in rows]
+
+ async def close(self):
+ if self._db:
+ await self._db.close()
diff --git a/backend/services/led_service.py b/backend/services/led_service.py
index c43af06..1ed890b 100644
--- a/backend/services/led_service.py
+++ b/backend/services/led_service.py
@@ -1,13 +1,14 @@
-"""Service de contrôle de l'anneau LED WS2812b (GPIO18, 35 LEDs).
+"""Service de controle de l'anneau LED WS2812b (GPIO18, 35 LEDs).
-Mode réel : rpi_ws281x (Raspberry Pi, doit tourner en root ou avec /dev/mem)
-Mode mock : log des opérations uniquement (développement)
+Mode reel : rpi_ws281x (Raspberry Pi, root ou /dev/mem)
+Mode mock : log uniquement (developpement)
"""
from __future__ import annotations
import asyncio
import logging
+import math
import threading
import time
from typing import Callable
@@ -16,13 +17,12 @@ from backend.services.config_service import Config, LEDConfig
logger = logging.getLogger(__name__)
-# ── Tentative d'import de rpi_ws281x ─────────────────────────────────────────
try:
- from rpi_ws281x import PixelStrip, Color as WS_Color, ws
+ from rpi_ws281x import PixelStrip, Color as WS_Color
HAS_WS281X = True
except ImportError:
HAS_WS281X = False
- logger.warning("rpi_ws281x non disponible — mode mock LED activé")
+ logger.warning("rpi_ws281x non disponible -- mode mock LED")
class WS_Color: # type: ignore
def __init__(self, r: int, g: int, b: int):
@@ -31,7 +31,7 @@ except ImportError:
return f"Color({self.r},{self.g},{self.b})"
class PixelStrip: # type: ignore
- def __init__(self, *args, **kwargs): pass
+ def __init__(self, *a, **kw): pass
def begin(self): pass
def show(self): pass
def setPixelColor(self, i, c): pass
@@ -40,16 +40,10 @@ except ImportError:
def _color(rgb: list[int]) -> WS_Color:
- return WS_Color(rgb[0], rgb[1], rgb[2])
-
-
-def _lerp(a: int, b: int, t: float) -> int:
- return int(a + (b - a) * t)
+ return WS_Color(int(rgb[0]), int(rgb[1]), int(rgb[2]))
class LEDService:
- """Contrôle l'anneau LED via un thread dédié + queue de commandes."""
-
EFFECTS = ("idle", "countdown", "capture", "captured", "finished",
"printing", "error", "disabled", "off")
@@ -74,16 +68,16 @@ class LEDService:
self._cfg.pin,
self._cfg.freq_hz,
self._cfg.dma,
- False, # invert
+ False,
self._cfg.brightness,
- 0, # channel
+ 0,
)
try:
self._strip.begin()
- logger.info("Strip WS2812b initialisé (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
+ logger.info("Strip WS2812b initialise (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
except Exception as e:
logger.error("Erreur init strip LED: %s", e)
- self._strip = PixelStrip() # fallback mock
+ self._strip = PixelStrip()
else:
self._strip = PixelStrip()
@@ -104,10 +98,9 @@ class LEDService:
def set_on_change(self, cb: Callable):
self._on_change_cb = cb
- # ── Commandes publiques (thread-safe) ────────────────────────────────────
+ # ── Commandes publiques ───────────────────────────────────────────────────
def play(self, effect: str, countdown_duration: float = 5.0):
- """Joue un effet nommé. Thread-safe."""
with self._lock:
self._current_effect = effect
self._countdown_duration = countdown_duration
@@ -116,22 +109,21 @@ class LEDService:
self._notify_change(effect)
def set_color(self, r: int, g: int, b: int):
- """Couleur fixe immédiate."""
self._fill(WS_Color(r, g, b))
# ── Thread principal ──────────────────────────────────────────────────────
def _run(self):
effect_func = {
- "idle": self._effect_idle,
- "countdown": self._effect_countdown,
- "capture": self._effect_capture,
- "captured": self._effect_solid,
- "finished": self._effect_finished,
- "printing": self._effect_spin,
- "error": self._effect_error,
- "disabled": self._effect_solid,
- "off": self._effect_off,
+ "idle": self._effect_idle,
+ "countdown": self._effect_countdown,
+ "capture": self._effect_capture,
+ "captured": self._effect_solid,
+ "finished": self._effect_finished,
+ "printing": self._effect_spin,
+ "error": self._effect_error,
+ "disabled": self._effect_solid,
+ "off": self._effect_off,
}
while not self._stop_event.is_set():
@@ -143,19 +135,16 @@ class LEDService:
fn = effect_func.get(current, self._effect_idle)
try:
- if current in ("countdown",):
+ if current == "countdown":
fn(cd_dur)
else:
fn()
except Exception as e:
logger.error("Erreur effet LED '%s': %s", current, e)
- # Si l'effet s'est terminé naturellement (ex: finished → retour idle)
- # on vérifie si un nouveau cmd est arrivé
if not self._cmd_event.is_set() and not self._stop_event.is_set():
with self._lock:
if self._current_effect == current:
- # Retour automatique à idle après effets ponctuels
if current in ("capture", "captured", "finished", "error"):
self._current_effect = "idle"
self._cmd_event.wait(timeout=0.1)
@@ -163,27 +152,23 @@ class LEDService:
# ── Effets ───────────────────────────────────────────────────────────────
def _effect_idle(self):
- """Respiration bleue douce."""
cfg = self._cfg.get_effect("idle")
c = cfg.color
speed = cfg.speed
- n = self._cfg.count
step = 0
while not self._cmd_event.is_set() and not self._stop_event.is_set():
- t = (1 + __import__("math").sin(step * 0.1)) / 2 # 0..1
+ t = (1 + math.sin(step * 0.1)) / 2
brightness = max(0.05, t)
- color = WS_Color(
+ self._fill(WS_Color(
int(c[0] * brightness),
int(c[1] * brightness),
int(c[2] * brightness),
- )
- self._fill(color)
+ ))
time.sleep(speed)
step += 1
def _effect_countdown(self, duration: float = 5.0):
- """Remplissage progressif vert LED par LED."""
cfg = self._cfg.get_effect("countdown")
c = cfg.color
n = self._cfg.count
@@ -191,55 +176,68 @@ class LEDService:
color = WS_Color(c[0], c[1], c[2])
self._fill(blank)
-
t_start = time.time()
while not self._cmd_event.is_set() and not self._stop_event.is_set():
elapsed = time.time() - t_start
ratio = min(elapsed / duration, 1.0)
leds_on = int(ratio * n)
-
for i in range(n):
self._strip.setPixelColor(i, color if i < leds_on else blank)
self._strip.show()
-
if ratio >= 1.0:
break
time.sleep(0.04)
def _effect_capture(self):
- """Flash blanc."""
+ """Flash photo.
+
+ Utilise la couleur configuree dans settings.yaml > leds > effects > capture.
+ Booste la luminosite au maximum pendant le flash pour maximiser l'eclairage,
+ puis la restaure a la valeur normale.
+
+ Conseil couleur WS2812b : les LEDs bleues sont plus efficaces que les rouges.
+ Pour un blanc neutre/chaud utilisez par ex. [255, 180, 60] au lieu de [255,255,255].
+ """
cfg = self._cfg.get_effect("capture")
- white = WS_Color(255, 255, 255)
+ flash_color = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
off = WS_Color(0, 0, 0)
- for _ in range(cfg.flashes):
- if self._cmd_event.is_set():
- break
- self._fill(white)
- time.sleep(cfg.flash_duration)
+
+ # Boost luminosite max pendant le flash
+ if HAS_WS281X and self._strip:
+ self._strip.setBrightness(255)
+
+ try:
+ for _ in range(cfg.flashes):
+ if self._cmd_event.is_set():
+ break
+ self._fill(flash_color)
+ time.sleep(cfg.flash_duration)
+ self._fill(off)
+ # Pause inter-flash plus courte que le flash lui-meme
+ if _ < cfg.flashes - 1:
+ time.sleep(cfg.flash_duration * 0.4)
+ finally:
+ # Toujours restaurer la luminosite normale meme en cas d'erreur
+ if HAS_WS281X and self._strip:
+ self._strip.setBrightness(self._cfg.brightness)
self._fill(off)
- time.sleep(cfg.flash_duration)
def _effect_solid(self):
- """Couleur pleine selon l'effet courant."""
with self._lock:
current = self._current_effect
cfg = self._cfg.get_effect(current)
self._fill(WS_Color(*cfg.color))
- # Attente jusqu'à prochain cmd
self._cmd_event.wait()
def _effect_finished(self):
- """Bleu fixe pendant duration secondes, puis retour idle."""
cfg = self._cfg.get_effect("finished")
self._fill(WS_Color(*cfg.color))
self._cmd_event.wait(timeout=cfg.duration)
def _effect_spin(self):
- """Rotation d'une traînée de LEDs."""
cfg = self._cfg.get_effect("printing")
c = cfg.color
n = self._cfg.count
- speed = cfg.speed
tail = 6
pos = 0
@@ -248,22 +246,18 @@ class LEDService:
dist = (i - pos) % n
if dist < tail:
factor = (tail - dist) / tail
- self._strip.setPixelColor(
- i,
- WS_Color(
- int(c[0] * factor),
- int(c[1] * factor),
- int(c[2] * factor),
- ),
- )
+ self._strip.setPixelColor(i, WS_Color(
+ int(c[0] * factor),
+ int(c[1] * factor),
+ int(c[2] * factor),
+ ))
else:
self._strip.setPixelColor(i, WS_Color(0, 0, 0))
self._strip.show()
pos = (pos + 1) % n
- time.sleep(speed)
+ time.sleep(cfg.speed)
def _effect_error(self):
- """Flash rouge."""
cfg = self._cfg.get_effect("error")
red = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
off = WS_Color(0, 0, 0)
diff --git a/backend/services/photobooth_service.py b/backend/services/photobooth_service.py
index 25bdb9e..2e3ac0e 100644
--- a/backend/services/photobooth_service.py
+++ b/backend/services/photobooth_service.py
@@ -125,5 +125,42 @@ class PhotoboothService:
results.append(str(f))
return sorted(results)
+ # ── UI / private.css ──────────────────────────────────────────────────────
+
+ _CSS_HIDE_DELETE = """\
+/* JH Photomaton — généré automatiquement, ne pas modifier manuellement */
+/* Cacher le bouton Supprimer sur l'écran de review après capture */
+.action-button-delete {
+ display: none !important;
+}
+"""
+
+ _CSS_SHOW_DELETE = """\
+/* JH Photomaton — généré automatiquement, ne pas modifier manuellement */
+/* Bouton Supprimer visible (activé depuis le dashboard JH Photomaton) */
+/* .action-button-delete { display: none !important; } */
+"""
+
+ async def set_delete_button_visible(self, visible: bool) -> bool:
+ """Écrit private.css dans userdata pour afficher ou cacher le bouton Supprimer.
+
+ photobooth-app charge automatiquement userdata/private.css à chaque requête
+ — aucun redémarrage nécessaire, effectif dès la prochaine capture.
+ """
+ css_path = Path(self._cfg.userdata_dir) / "private.css"
+ try:
+ css_content = self._CSS_SHOW_DELETE if visible else self._CSS_HIDE_DELETE
+ css_path.parent.mkdir(parents=True, exist_ok=True)
+ css_path.write_text(css_content, encoding="utf-8")
+ logger.info("private.css mis à jour : bouton delete %s", "visible" if visible else "caché")
+ return True
+ except Exception as e:
+ logger.error("Impossible d'écrire private.css : %s", e)
+ return False
+
+ async def get_delete_button_visible(self) -> bool:
+ """Lit l'état actuel depuis le fichier private.css (ou depuis la config)."""
+ return self._cfg.show_delete_button
+
async def close(self):
await self._client.aclose()
diff --git a/backend/services/printer_service.py b/backend/services/printer_service.py
index 75eb15e..f5f280f 100644
--- a/backend/services/printer_service.py
+++ b/backend/services/printer_service.py
@@ -100,6 +100,35 @@ class PrinterService:
async def get_pending(self) -> list[dict]:
return await self.get_queue("pending")
+ async def get_pending_by_photo_id(self) -> dict[str, list]:
+ """Retourne un dict {photo_id_stem: [entries]} pour toutes les demandes actives.
+
+ Permet à la galerie admin de savoir quelles photos ont une demande en attente
+ sans modifier le schéma SQLite — on match par stem du filename.
+ """
+ rows = await self.get_queue("pending")
+ # Aussi inclure celles "printing" (en cours d'impression)
+ rows += await self.get_queue("printing")
+
+ result: dict[str, list] = {}
+ for r in rows:
+ from pathlib import Path
+ stem = Path(r["filename"]).stem
+ result.setdefault(stem, []).append(r)
+ return result
+
+ async def cancel_by_photo_id(self, photo_stem: str) -> int:
+ """Annule toutes les demandes pending pour un photo_id donné. Retourne le nb annulé."""
+ pending = await self.get_queue("pending")
+ from pathlib import Path
+ cancelled = 0
+ for r in pending:
+ if Path(r["filename"]).stem == photo_stem:
+ ok = await self.cancel(r["id"])
+ if ok:
+ cancelled += 1
+ return cancelled
+
async def cancel(self, entry_id: str) -> bool:
async with self._lock:
cursor = await self._db.execute(
@@ -186,48 +215,105 @@ class PrinterService:
except Exception as e:
return {"success": False, "error": str(e)}
+ # ── Helpers ───────────────────────────────────────────────────────────────
+
+ def _printer_name(self, p) -> str:
+ """Accepte une entrée printers qui soit un str ou un dict."""
+ return p["name"] if isinstance(p, dict) else str(p)
+
+ def _printer_label(self, p) -> str:
+ return p.get("label", self._printer_name(p)) if isinstance(p, dict) else str(p)
+
# ── Statut imprimantes CUPS ───────────────────────────────────────────────
async def get_printers_status(self) -> list[dict]:
- """Retourne le statut des imprimantes CUPS configurées."""
+ """Retourne le statut détaillé des imprimantes CUPS configurées."""
statuses = []
for p in self._cfg.printers:
- status = await asyncio.to_thread(self._get_printer_status, p["name"])
+ name = self._printer_name(p)
+ status = await asyncio.to_thread(self._get_printer_status, name)
statuses.append({
- "name": p["name"],
- "label": p["label"],
+ "name": name,
+ "label": self._printer_label(p),
**status,
})
return statuses
def _get_printer_status(self, printer_name: str) -> dict:
+ """Statut CUPS complet pour une imprimante (état, jobs, accepting)."""
try:
- result = subprocess.run(
+ # État de l'imprimante
+ r_state = subprocess.run(
["lpstat", "-p", printer_name],
capture_output=True, text=True, timeout=5
)
- output = result.stdout.lower()
- if "idle" in output:
+ out = r_state.stdout.lower()
+
+ if r_state.returncode != 0 or "not found" in (r_state.stderr or "").lower():
+ return {"state": "offline", "accepting": False, "jobs": [], "jobs_count": 0}
+
+ if "idle" in out:
state = "idle"
- elif "printing" in output or "processing" in output:
+ elif "printing" in out or "processing" in out:
state = "printing"
- elif "disabled" in output:
+ elif "stopped" in out or "disabled" in out:
state = "disabled"
- elif "not found" in output or result.returncode != 0:
- state = "offline"
else:
state = "unknown"
- # Compte les jobs en attente
- jobs_result = subprocess.run(
- ["lpstat", "-o", printer_name],
+ # Est-ce que l'imprimante accepte les nouveaux jobs ?
+ r_accept = subprocess.run(
+ ["lpstat", "-a", printer_name],
capture_output=True, text=True, timeout=5
)
- jobs = len([l for l in jobs_result.stdout.strip().splitlines() if l])
+ accepting = "accepting" in r_accept.stdout.lower()
- return {"state": state, "jobs": jobs}
+ # Liste des jobs CUPS en cours
+ jobs = self._get_cups_jobs(printer_name)
+
+ return {
+ "state": state,
+ "accepting": accepting,
+ "jobs": jobs,
+ "jobs_count": len(jobs),
+ }
except Exception as e:
- return {"state": "error", "jobs": 0, "error": str(e)}
+ return {"state": "error", "accepting": False, "jobs": [], "jobs_count": 0, "error": str(e)}
+
+ def _get_cups_jobs(self, printer_name: str) -> list[dict]:
+ """Retourne la liste des jobs CUPS en cours pour une imprimante."""
+ try:
+ r = subprocess.run(
+ ["lpstat", "-o", printer_name, "-l"],
+ capture_output=True, text=True, timeout=5
+ )
+ jobs = []
+ current = {}
+ for line in r.stdout.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ if line.startswith(printer_name):
+ if current:
+ jobs.append(current)
+ parts = line.split()
+ current = {
+ "id": parts[0] if parts else "",
+ "user": parts[1] if len(parts) > 1 else "",
+ "size": parts[3] if len(parts) > 3 else "",
+ "date": " ".join(parts[4:7]) if len(parts) > 6 else "",
+ "details": [],
+ }
+ elif current and ":" in line:
+ current.setdefault("details", []).append(line)
+ if current:
+ jobs.append(current)
+ return jobs
+ except Exception:
+ return []
+
+ async def get_cups_jobs(self, printer_name: str) -> list[dict]:
+ return await asyncio.to_thread(self._get_cups_jobs, printer_name)
async def cancel_cups_jobs(self, printer_name: str) -> bool:
"""Annule tous les jobs CUPS pour une imprimante."""
@@ -240,3 +326,43 @@ class PrinterService:
except Exception as e:
logger.error("Erreur cancel CUPS: %s", e)
return False
+
+ async def cancel_cups_job(self, job_id: str) -> bool:
+ """Annule un job CUPS précis."""
+ try:
+ result = subprocess.run(
+ ["cancel", job_id],
+ capture_output=True, text=True, timeout=10
+ )
+ return result.returncode == 0
+ except Exception as e:
+ logger.error("Erreur cancel CUPS job %s: %s", job_id, e)
+ return False
+
+ async def enable_printer(self, printer_name: str) -> bool:
+ """Active l'imprimante CUPS (cupsenable) et accepte les nouveaux jobs."""
+ try:
+ r1 = subprocess.run(["cupsenable", printer_name], capture_output=True, timeout=10)
+ r2 = subprocess.run(["cupsaccept", printer_name], capture_output=True, timeout=10)
+ return r1.returncode == 0 and r2.returncode == 0
+ except Exception as e:
+ logger.error("Erreur enable printer %s: %s", printer_name, e)
+ return False
+
+ async def disable_printer(self, printer_name: str) -> bool:
+ """Désactive l'imprimante CUPS (cupsdisable)."""
+ try:
+ result = subprocess.run(["cupsdisable", printer_name], capture_output=True, timeout=10)
+ return result.returncode == 0
+ except Exception as e:
+ logger.error("Erreur disable printer %s: %s", printer_name, e)
+ return False
+
+ async def reject_jobs(self, printer_name: str) -> bool:
+ """Refuse les nouveaux jobs (cupsreject) sans stopper l'impression en cours."""
+ try:
+ result = subprocess.run(["cupsreject", printer_name], capture_output=True, timeout=10)
+ return result.returncode == 0
+ except Exception as e:
+ logger.error("Erreur reject printer %s: %s", printer_name, e)
+ return False
diff --git a/config/settings.yaml b/config/settings.yaml
index dc70c17..e60b308 100644
--- a/config/settings.yaml
+++ b/config/settings.yaml
@@ -1,5 +1,5 @@
# ============================================================
-# JH Photomaton — Configuration principale
+# JH Photomaton -- Configuration principale
# Association Les Sapins Du Web (LSDW)
# ============================================================
@@ -11,30 +11,37 @@ app:
secret_key: "photomaton-jh-2026-secret-change-me"
admin_password: "PhotoBooth2026!"
-# --- Intégration photobooth-app ---
+# --- Integration photobooth-app ---
photobooth:
- base_url: "http://localhost:8083"
+ base_url: "http://localhost:8000"
data_dir: "/home/pi/photobooth-data"
config_file: "/home/pi/.config/photobooth-app/config.json"
media_dir: "/home/pi/photobooth-data/media/processed_full"
userdata_dir: "/home/pi/photobooth-data/userdata"
+ show_delete_button: false
+
+# --- Evenement en cours ---
+event:
+ name: "Evenement"
+ slug: "evenement"
+ started_at: 0
# --- Bouton physique ---
button:
- pin: 23 # GPIO BCM (input, pull-up interne)
- relay_pin: 12 # GPIO BCM (output, relay ON/OFF bouton 12V)
+ pin: 23
+ relay_pin: 12
debounce_ms: 50
- double_click_ms: 400 # Délai max entre clics pour multi-clic
- long_press_ms: 1500 # Durée min long appui
- max_clicks: 4 # Nombre max de clics reconnus
- long_press_action: "print_last" # print_last | none
+ double_click_ms: 400
+ long_press_ms: 1500
+ max_clicks: 4
+ long_press_action: "print_last"
print_enabled: true
# --- Anneau LED WS2812b ---
leds:
- pin: 18 # GPIO BCM (PWM hardware, pin physique 12)
- count: 35 # Nombre de LEDs dans l'anneau
- brightness: 180 # 0-255
+ pin: 18
+ count: 35
+ brightness: 180 # Luminosite normale (0-255)
freq_hz: 800000
dma: 10
strip_type: "WS2812"
@@ -43,36 +50,51 @@ leds:
color: [0, 30, 80]
mode: "breathe"
speed: 0.025
+
countdown:
color: [0, 200, 80]
mode: "fill_progressive"
+
capture:
- color: [255, 255, 255]
+ # BLANC CHAUD pour photo -- les WS2812b ont une LED bleue tres efficace.
+ # [255,255,255] donne un rendu tres bleu sur les photos.
+ # Reduisez le bleu (3eme valeur) et augmentez le rouge pour corriger.
+ # Exemples :
+ # [255, 200, 80] -> blanc chaud (recommande pour debuter)
+ # [255, 180, 40] -> blanc tres chaud / jaune
+ # [255, 220, 120] -> blanc neutre legerement chaud
+ color: [255, 200, 80]
mode: "flash"
flashes: 2
- flash_duration: 0.1
+ # Duree en secondes de chaque flash (plus long = plus de lumiere)
+ flash_duration: 0.30
+
captured:
color: [150, 0, 200]
mode: "solid"
+
finished:
color: [0, 100, 255]
mode: "solid"
duration: 2.0
+
printing:
color: [0, 120, 255]
mode: "spin"
speed: 0.05
+
error:
color: [220, 0, 0]
mode: "flash"
flashes: 4
+
disabled:
color: [60, 0, 0]
mode: "solid"
# --- Impression ---
print:
- mode: "validation" # direct | gallery | validation
+ mode: "validation"
script_path: "/home/pi/photobooth-data/script/script_print.sh"
default_copies: 1
printers:
@@ -88,9 +110,8 @@ gallery:
qr_base_url: "https://photomaton.lessapinsduweb.com"
# --- Mapping clics bouton -> actions photobooth-app ---
-# n clics -> photobooth_index dans la liste actions.image de config.json
button_actions:
1: { label: "Photo normale", photobooth_index: 0 }
- 2: { label: "Photo étoile", photobooth_index: 1 }
+ 2: { label: "Photo etoile", photobooth_index: 1 }
3: { label: "Photo cailloux", photobooth_index: 2 }
- 4: { label: "Photo soirée", photobooth_index: 3 }
+ 4: { label: "Photo soiree", photobooth_index: 3 }
diff --git a/docs/BACKUP-RESTORE.md b/docs/BACKUP-RESTORE.md
new file mode 100644
index 0000000..b48d97f
--- /dev/null
+++ b/docs/BACKUP-RESTORE.md
@@ -0,0 +1,176 @@
+# Sauvegarde & Restauration — JH Photomaton
+
+## Sauvegarder
+
+```bash
+sudo bash /home/pi/jh-photomaton/scripts/backup-configs.sh
+# → crée des archives dans ~/photomaton-backups/
+```
+
+Pour sauvegarder vers une clé USB :
+```bash
+sudo bash /home/pi/jh-photomaton/scripts/backup-configs.sh /media/pi/USB
+```
+
+---
+
+## Restauration RaspAP
+
+```bash
+# Réinstaller RaspAP sur un Pi frais
+curl -sL https://install.raspap.com | bash
+
+# Puis restaurer la config :
+sudo tar -xzf raspap-YYYYMMDD-HHMMSS.tar.gz -C /
+sudo systemctl restart hostapd dnsmasq dhcpcd lighttpd
+```
+
+**Fichiers critiques restaurés :**
+- `/etc/hostapd/hostapd.conf` — SSID, mot de passe WiFi, canal
+- `/etc/dnsmasq.conf` + `/etc/dnsmasq.d/` — DHCP & DNS
+- `/etc/dhcpcd.conf` — IP statique 10.3.141.1 sur wlan0
+
+**Vérification :**
+```bash
+sudo systemctl status hostapd dnsmasq
+# Le réseau "Photomaton" doit apparaître dans les WiFi disponibles
+```
+
+---
+
+## Restauration Zoraxy
+
+```bash
+# Télécharger le binaire (même version qu'avant)
+mkdir ~/zoraxy && cd ~/zoraxy
+wget https://github.com/tobychui/zoraxy/releases/download/v3.3.2/zoraxy_linux_arm64 -O zoraxy
+chmod +x zoraxy
+
+# Restaurer la config (rules, settings)
+tar -xzf zoraxy-YYYYMMDD-HHMMSS.tar.gz -C ~/zoraxy/
+
+# Restaurer les certificats Let's Encrypt
+sudo tar -xzf letsencrypt-YYYYMMDD-HHMMSS.tar.gz -C /
+
+# Réinstaller le service systemd
+sudo cp ~/jh-photomaton/systemd/zoraxy.service /etc/systemd/system/
+# ou recréer le fichier (voir chapitre 6 du guide d'installation)
+sudo systemctl daemon-reload
+sudo systemctl enable zoraxy
+sudo systemctl start zoraxy
+```
+
+**Vérification :**
+```bash
+sudo systemctl status zoraxy
+curl -sk https://photomaton.lessapinsduweb.com/ | head -3
+```
+
+**Note sur les certificats :** Let's Encrypt expire tous les 90 jours.
+Pour renouveler (Pi connecté à internet via Ethernet) :
+```bash
+sudo certbot renew
+# Puis ré-importer dans Zoraxy → TLS/SSL → Import Certificate
+```
+
+---
+
+## Restauration CUPS (Imprimantes)
+
+### Option A — Restaurer depuis l'archive (Pi de remplacement)
+
+```bash
+# Installer CUPS
+sudo apt install -y cups printer-driver-gutenprint
+sudo systemctl start cups
+
+# Restaurer la config
+sudo tar -xzf cups-YYYYMMDD-HHMMSS.tar.gz -C /
+sudo systemctl restart cups
+
+# Vérifier
+lpstat -p
+# Selphy_Blanche_WiFi et Selphy_Noire_WiFi doivent apparaître
+```
+
+### Option B — Réajouter les imprimantes manuellement (plus fiable après un crash)
+
+```bash
+# Brancher les Canon Selphy en USB, puis :
+sudo usermod -aG lpadmin pi
+http://localhost:631 # Interface web CUPS → Administration → Add Printer
+# Nom: Selphy_Blanche_WiFi (EXACTEMENT ce nom — utilisé par le script d'impression)
+# Driver: Canon SELPHY CP1300 - Gutenprint
+```
+
+**Vérification après restauration :**
+```bash
+# Test d'impression (adapter le chemin)
+/home/pi/photobooth-data/script/script_print.sh \
+ "/home/pi/photobooth-data/media/processed_full/test.jpg" \
+ "image" "test" "1"
+# Doit retourner : PRINTED:Selphy_Blanche_WiFi:1
+```
+
+---
+
+## Restauration JH Photomaton
+
+```bash
+cd /home/pi
+git clone https://gitea.lespatas.ovh/admin/photoBooth.git jh-photomaton
+cd jh-photomaton
+
+# Restaurer la config locale (settings.yaml + base SQLite)
+tar -xzf jh-photomaton-YYYYMMDD-HHMMSS.tar.gz -C /home/pi/jh-photomaton/
+
+# Restaurer la config photobooth-app
+tar -xzf photobooth-app-config-YYYYMMDD-HHMMSS.tar.gz -C /
+
+# Installer
+sudo bash scripts/install.sh
+
+# Démarrer
+sudo systemctl start jh-photomaton
+sudo systemctl status jh-photomaton
+```
+
+---
+
+## Sauvegarde automatique (planifiée)
+
+Pour sauvegarder chaque semaine automatiquement :
+```bash
+# Ajouter dans crontab root
+sudo crontab -e
+
+# Sauvegarde chaque dimanche à 3h du matin
+0 3 * * 0 bash /home/pi/jh-photomaton/scripts/backup-configs.sh /home/pi/photomaton-backups >> /home/pi/photomaton-backups/backup.log 2>&1
+
+# Nettoyer les backups de plus de 30 jours
+0 4 * * 0 find /home/pi/photomaton-backups -name "*.tar.gz" -mtime +30 -delete
+```
+
+Ou via JH Photomaton (depuis le panel admin → Réglages, à implémenter) :
+```bash
+# Déclencher une sauvegarde manuelle depuis le dashboard
+curl -X POST http://localhost:8090/api/system/backup
+```
+
+---
+
+## Sauvegarde de la carte SD entière
+
+La méthode la plus sûre avant un event :
+
+```bash
+# Sur le PC (Linux/macOS) avec la carte SD insérée
+# Identifier la carte : lsblk ou diskutil list (macOS)
+sudo dd if=/dev/sdX bs=4M status=progress | gzip > photomaton-$(date +%Y%m%d).img.gz
+
+# Restaurer
+zcat photomaton-YYYYMMDD.img.gz | sudo dd of=/dev/sdX bs=4M status=progress
+```
+
+> Remplacer `/dev/sdX` par le device de la carte SD (ex: `/dev/sdb` ou `/dev/disk2`).
+> Une image complète 32 Go compressée fait ~4-8 Go selon l'occupation.
diff --git a/docs/INSTALL-FROM-SCRATCH.md b/docs/INSTALL-FROM-SCRATCH.md
new file mode 100644
index 0000000..9fcd42a
--- /dev/null
+++ b/docs/INSTALL-FROM-SCRATCH.md
@@ -0,0 +1,668 @@
+# Guide d'installation from scratch — JH Photomaton
+**Raspberry Pi 4 (2 Go) · Pi Camera Module V3 · Pi OS Lite 64-bit + cage (kiosk Wayland)**
+*Les Sapins Du Web — juillet 2026*
+
+---
+
+## Vue d'ensemble du stack
+
+```
+Internet / réseau local
+ │
+ [Zoraxy] :443 / :80 — reverse proxy HTTPS
+ │
+ [RaspAP] — point d'accès WiFi "Photomaton"
+ │
+ ┌──────┴──────────────────────┐
+ │ photobooth-app :8000 │ — kiosque, caméra, frames
+ │ JH Photomaton :8090 │ — admin, LEDs, bouton, imprimantes
+ │ CUPS :631 │ — Canon Selphy CP1300 x2
+ └─────────────────────────────┘
+```
+
+**Matériel :**
+- Raspberry Pi 4 (2 Go RAM), carte SD 32 Go+
+- Pi Camera Module V3 (Sony IMX708, 12 MP)
+- Anneau LED WS2812b — 35 LEDs — GPIO 18
+- Bouton arcade — GPIO 23 / Relay — GPIO 12
+- 2× Canon Selphy CP1300
+
+---
+
+## Étape 1 — Flasher la carte SD
+
+Utiliser **Raspberry Pi Imager** sur ton PC :
+
+- OS : **Raspberry Pi OS Lite (64-bit)** — sans bureau *(choisir "Raspberry Pi OS (other)" → "Lite")*
+- Activer SSH, définir utilisateur `pi` et mot de passe dans les options avancées
+- Hostname : `photomaton`
+- WiFi : **ne pas configurer** (RaspAP va le prendre en charge)
+
+> **Pourquoi Lite ?**
+> Le bureau complet (PIXEL) consomme ~450–600 Mo RAM à l'idle.
+> Lite + cage + Chromium consomme ~150–250 Mo — soit ~300 Mo libérés pour picamera2
+> et le reste du stack.
+
+> **Bookworm vs Trixie :** Bookworm est plus stable pour picamera2 et rpi-ws281x.
+> Si l'ancienne carte tournait sur Trixie sans problème, reste sur Trixie.
+
+Flasher, insérer dans le Pi, brancher en Ethernet, démarrer.
+
+---
+
+## Étape 2 — Premier démarrage et préparation système
+
+```bash
+# Trouver l'IP du Pi sur ton réseau local (depuis ton PC)
+# Puis se connecter
+ssh pi@
+
+# Mise à jour complète
+sudo apt update && sudo apt full-upgrade -y
+sudo apt autoremove -y
+
+# Outils essentiels
+sudo apt install -y git curl wget vim rsync python3-pip python3-venv \
+ python3-dev build-essential gcc make
+
+# Redémarrer pour appliquer les mises à jour du noyau
+sudo reboot
+```
+
+---
+
+## Étape 3 — Désactiver l'audio (requis pour les LEDs WS2812b)
+
+Les LEDs WS2812b utilisent le PWM sur GPIO 18, qui entre en conflit avec la carte son intégrée.
+
+```bash
+sudo nano /boot/firmware/config.txt
+```
+
+Trouver la ligne `dtparam=audio=on` et la remplacer par :
+
+```
+dtparam=audio=off
+```
+
+Ajouter aussi en bas du fichier (si pas déjà présent) :
+
+```
+# Désactive le Bluetooth pour libérer l'UART si besoin
+dtoverlay=disable-bt
+```
+
+Sauvegarder et redémarrer :
+
+```bash
+sudo reboot
+```
+
+---
+
+## Étape 4 — Installer l'environnement kiosk (cage + Chromium)
+
+Sur Pi OS Lite, il n'y a pas de bureau. On installe uniquement ce qu'il faut pour afficher Chromium en mode kiosque via le compositeur Wayland minimal **cage**.
+
+```bash
+sudo apt install -y \
+ cage \
+ chromium-browser \
+ xdg-utils \
+ fonts-liberation \
+ fonts-noto \
+ dbus-user-session \
+ libcap2-bin
+```
+
+### Auto-login console
+
+```bash
+sudo raspi-config
+# → System Options → Boot / Auto Login → Console Autologin
+```
+
+### Service kiosk systemd
+
+Ce service démarre cage + Chromium automatiquement au boot, après que photobooth-app soit prêt.
+
+```bash
+sudo nano /etc/systemd/system/photobooth-kiosk.service
+```
+
+```ini
+[Unit]
+Description=Photobooth Kiosk (cage + Chromium)
+After=photobooth-app.service network.target
+Wants=photobooth-app.service
+
+[Service]
+Type=simple
+User=pi
+PAMName=login
+TTYPath=/dev/tty1
+StandardInput=tty
+Environment=XDG_RUNTIME_DIR=/run/user/1000
+Environment=WAYLAND_DISPLAY=wayland-1
+
+# Attendre que photobooth-app soit prêt
+ExecStartPre=/bin/sleep 8
+ExecStart=/usr/bin/cage -- /usr/bin/chromium-browser \
+ --kiosk \
+ --noerrdialogs \
+ --disable-infobars \
+ --disable-session-crashed-bubble \
+ --disable-features=TranslateUI \
+ --no-first-run \
+ --disable-restore-session-state \
+ --autoplay-policy=no-user-gesture-required \
+ http://localhost:8000
+
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable photobooth-kiosk
+```
+
+> **Note :** cage est un compositeur Wayland "single-app" — il lance Chromium et rien d'autre.
+> Pas de gestionnaire de fenêtres, pas de barre des tâches, pas de bureau.
+> C'est exactement ce qu'on veut pour un kiosque.
+
+---
+
+## Étape 5 — Activer la caméra
+
+```bash
+# Installer picamera2 et les outils libcamera (paquets système)
+sudo apt install -y python3-picamera2 libcamera-apps
+
+# Vérifier que la caméra est détectée
+libcamera-hello --list-cameras
+# Doit afficher : "Available cameras" avec le Sony IMX708
+```
+
+---
+
+## Étape 6 — Installer photobooth-app
+
+photobooth-app est l'application kiosque qui gère la caméra, le décompte et les frames.
+
+```bash
+# Créer le dossier de données
+mkdir -p /home/pi/photobooth-data/{media/processed_full,userdata,script}
+
+# Installer dans un venv avec accès aux paquets système (nécessaire pour picamera2)
+sudo apt install -y python3-venv
+python3 -m venv /home/pi/photobooth-venv --system-site-packages
+/home/pi/photobooth-venv/bin/pip install photobooth-app
+```
+
+### Créer le service systemd pour photobooth-app
+
+```bash
+sudo nano /etc/systemd/system/photobooth-app.service
+```
+
+```ini
+[Unit]
+Description=Photobooth App
+After=network.target
+
+[Service]
+Type=simple
+User=pi
+WorkingDirectory=/home/pi
+ExecStart=/home/pi/photobooth-venv/bin/photobooth
+Restart=always
+RestartSec=5
+Environment=HOME=/home/pi
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable photobooth-app
+sudo systemctl start photobooth-app
+
+# Attendre ~10s puis vérifier
+sudo systemctl status photobooth-app
+```
+
+photobooth-app est accessible sur `http://localhost:8000`.
+
+### Configurer photobooth-app
+
+Accéder à l'interface d'administration : `http://:8000/admin`
+
+**Réglages à faire impérativement :**
+
+#### a) Backend caméra
+
+`Settings → Backends → Add / Edit` :
+- Backend type : **Picamera2**
+- Camera num : **0**
+- Capture resolution : **4608 × 2592** *(mode natif IMX708 — 14 fps)*
+- Preview resolution : **2304 × 1296** *(mode natif — 56 fps, champ complet)*
+- Liveview resolution : **960 × 540** *(min. height=500 imposé par photobooth-app, 16:9)*
+- Framerate still mode : **14** *(max en full res)*
+
+#### b) Désactiver les GPIO internes de photobooth-app
+
+`Settings → Hardware → GPIO` → **désactiver** (JH Photomaton gère ses propres GPIO)
+
+```
+gpio_enabled: false
+```
+
+#### c) Plugin commander — connecter à JH Photomaton
+
+Copier la config depuis le dépôt après l'avoir cloné (étape 7) :
+
+```bash
+# Après avoir cloné le dépôt JH Photomaton :
+cp /home/pi/jh-photomaton/photobooth-app/config/plugin_commander_jh.json \
+ ~/.config/photobooth-app/plugin_commander.json
+
+sudo systemctl restart photobooth-app
+```
+
+#### d) Bouton "Demande d'impression"
+
+Dans photobooth-app `Settings → Share → Actions` :
+Remplacer la share_command :
+
+```
+curl http://127.0.0.1:8090/api/print/request?filename={filename}
+```
+
+> ⚠️ L'ancienne valeur pointait vers Node-RED (`:1880`). Bien remplacer par `:8090`.
+
+---
+
+## Étape 7 — Cloner et installer JH Photomaton
+
+```bash
+# Cloner depuis Gitea
+cd /home/pi
+git clone https://gitea.lespatas.ovh/admin/photoBooth.git jh-photomaton
+cd jh-photomaton
+
+# Lancer l'installation (crée le venv, installe les dépendances, active le service)
+sudo bash scripts/install.sh
+```
+
+Le script `install.sh` fait automatiquement :
+- `apt install` des dépendances système
+- Création du venv Python avec `--system-site-packages`
+- `pip install -r requirements.txt`
+- `pip install rpi-ws281x` (LEDs)
+- Copie du service systemd + `systemctl enable`
+
+### Vérifier l'installation
+
+```bash
+sudo systemctl status jh-photomaton
+sudo journalctl -u jh-photomaton -f
+```
+
+Interface admin : `http://:8090/admin`
+Mot de passe : **PhotoBooth2026!**
+
+---
+
+## Étape 8 — Configurer settings.yaml
+
+```bash
+nano /home/pi/jh-photomaton/config/settings.yaml
+```
+
+Points à vérifier / adapter :
+
+```yaml
+app:
+ port: 8090
+ admin_password: "PhotoBooth2026!" # changer si voulu
+
+photobooth:
+ base_url: "http://localhost:8000"
+ data_dir: "/home/pi/photobooth-data"
+ media_dir: "/home/pi/photobooth-data/media/processed_full"
+
+event:
+ name: "Evenement" # sera modifié depuis l'admin
+ slug: "evenement"
+
+leds:
+ pin: 18
+ count: 35 # nombre de LEDs dans l'anneau
+ brightness: 180 # 0-255, luminosité normale
+ effects:
+ capture:
+ color: [255, 200, 80] # blanc chaud (évite la dominante bleue WS2812b)
+ flashes: 2
+ flash_duration: 0.30
+
+button:
+ pin: 23 # GPIO du bouton arcade
+ relay_pin: 12 # GPIO du relay
+ print_enabled: true
+
+print:
+ mode: "validation"
+ printers:
+ - name: "Selphy_Blanche_WiFi"
+ label: "Selphy Blanche (WiFi)"
+ - name: "Selphy_Noire_WiFi"
+ label: "Selphy Noire (WiFi)"
+```
+
+Après modification :
+
+```bash
+sudo systemctl restart jh-photomaton
+```
+
+---
+
+## Étape 9 — Installer CUPS et les imprimantes Selphy
+
+```bash
+# Installer CUPS + pilotes Gutenprint
+sudo apt install -y cups printer-driver-gutenprint
+
+# Ajouter l'utilisateur pi au groupe lpadmin
+sudo usermod -aG lpadmin pi
+
+# Démarrer CUPS
+sudo systemctl enable cups
+sudo systemctl start cups
+
+# Autoriser l'accès distant à l'interface CUPS
+sudo cupsctl --remote-admin
+sudo systemctl restart cups
+```
+
+Accéder à CUPS : `http://:631`
+
+### Ajouter les imprimantes
+
+`Administration → Add Printer` pour chaque Selphy :
+
+| Champ | Valeur |
+|-------|--------|
+| Connection | `socket://192.168.X.X:9100` (IP de la Selphy sur le WiFi) ou USB |
+| Name | `Selphy_Blanche_WiFi` *(exactement ce nom — utilisé par le script)* |
+| Driver | Canon SELPHY CP1300 — Gutenprint |
+| Media | Postcard 100×148mm |
+
+Répéter pour `Selphy_Noire_WiFi`.
+
+### Script d'impression
+
+Vérifier que le script est exécutable et pointe vers les bons noms d'imprimantes :
+
+```bash
+ls -la /home/pi/photobooth-data/script/script_print.sh
+chmod +x /home/pi/photobooth-data/script/script_print.sh
+
+# Test manuel
+/home/pi/photobooth-data/script/script_print.sh \
+ "/home/pi/photobooth-data/media/processed_full/test.jpg" \
+ "image" "test" "1"
+# Doit retourner : PRINTED:Selphy_Blanche_WiFi:1
+```
+
+---
+
+## Étape 10 — Point d'accès WiFi (hostapd + dnsmasq)
+
+> **Pourquoi pas RaspAP ?**
+> RaspAP installe lighttpd + PHP + une interface web — ~40 Mo de RAM pour une UI qu'on n'utilise pas.
+> On configure directement hostapd et dnsmasq, ce qu'il y a en dessous.
+
+```bash
+sudo apt install -y hostapd dnsmasq
+sudo systemctl unmask hostapd
+```
+
+### IP statique sur wlan0
+
+Ajouter dans `/etc/dhcpcd.conf` :
+
+```ini
+interface wlan0
+ static ip_address=192.168.4.1/24
+ nohook wpa_supplicant
+```
+
+### hostapd — point d'accès WiFi ouvert
+
+```bash
+sudo nano /etc/hostapd/hostapd.conf
+```
+
+```ini
+interface=wlan0
+driver=nl80211
+ssid=Photomaton-LSDW
+hw_mode=g
+channel=6
+wmm_enabled=0
+macaddr_acl=0
+
+# WiFi ouvert — pas de mot de passe
+auth_algs=1
+ignore_broadcast_ssid=0
+```
+
+Déclarer le fichier de config dans `/etc/default/hostapd` :
+
+```ini
+DAEMON_CONF="/etc/hostapd/hostapd.conf"
+```
+
+### dnsmasq — DHCP + redirection DNS (portail captif)
+
+Ajouter à la fin de `/etc/dnsmasq.conf` :
+
+```ini
+interface=wlan0
+bind-interfaces
+
+# DHCP : distribue des IPs aux clients WiFi
+dhcp-range=192.168.4.2,192.168.4.100,255.255.255.0,24h
+# Annonce le Pi comme serveur DNS aux clients
+dhcp-option=6,192.168.4.1
+
+# ── Portail captif ──────────────────────────────────────────────
+# Tout le trafic DNS → Pi (192.168.4.1)
+# google.com, instagram.com, lessapinsduweb.com... tout arrive ici.
+# Zoraxy fait le tri par HTTP Host header :
+# - *.lessapinsduweb.com → apps configurées dans Zoraxy
+# - tout le reste → redirect vers photomaton.lessapinsduweb.com
+address=/#/192.168.4.1
+
+# Sondes de détection portail captif — iOS et Android ouvrent
+# automatiquement le navigateur sur la galerie
+address=/captive.apple.com/192.168.4.1
+address=/connectivitycheck.gstatic.com/192.168.4.1
+address=/detectportal.firefox.com/192.168.4.1
+address=/www.msftconnecttest.com/192.168.4.1
+```
+
+> **Note :** `address=/#/192.168.4.1` s'applique uniquement aux clients WiFi
+> (DHCP sur wlan0). Le Pi lui-même utilise le DNS de son routeur via eth0.
+
+### Activer les services
+
+```bash
+sudo systemctl enable --now hostapd
+sudo systemctl enable --now dnsmasq
+```
+
+---
+
+## Étape 11 — Installer Zoraxy (reverse proxy HTTPS + catch-all)
+
+Zoraxy sert deux rôles ici : reverse proxy HTTPS pour les apps, et redirection
+captive portal pour tout le trafic inconnu des clients WiFi.
+
+```bash
+# Télécharger le binaire ARM64
+mkdir ~/zoraxy && cd ~/zoraxy
+wget https://github.com/tobychui/zoraxy/releases/download/v3.3.2/zoraxy_linux_arm64 -O zoraxy
+chmod +x zoraxy
+
+# Service systemd
+sudo nano /etc/systemd/system/zoraxy.service
+```
+
+```ini
+[Unit]
+Description=Zoraxy Reverse Proxy
+After=network.target
+
+[Service]
+Type=simple
+User=pi
+WorkingDirectory=/home/pi/zoraxy
+ExecStart=/home/pi/zoraxy/zoraxy -port=8888
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable zoraxy
+sudo systemctl start zoraxy
+```
+
+### Configurer les routes dans Zoraxy
+
+| Hostname | Destination | Notes |
+|----------|-------------|-------|
+| `photomaton.lessapinsduweb.com` | `127.0.0.1:8000` | Galerie publique / kiosque |
+| `admin.lessapinsduweb.com` | `127.0.0.1:8090` | Interface admin JH Photomaton |
+| `cups.lessapinsduweb.com` | `127.0.0.1:631` | Optionnel |
+| **`*` (catch-all)** | **Redirect → `http://photomaton.lessapinsduweb.com`** | **Portail captif** |
+
+La règle catch-all est la clé : quand un client WiFi tape `google.com`, DNS
+résout vers `192.168.4.1`, Zoraxy reçoit la requête, ne reconnaît pas l'hôte,
+et redirige vers la galerie photo.
+
+> **Limite HTTPS :** si un client tape `https://google.com`, le navigateur voit
+> un certificat invalide et bloque avant la redirection. C'est inhérent à tout
+> portail DNS captif. En pratique : QR code sur les tables + iOS/Android qui
+> détectent le portail automatiquement via les sondes dnsmasq ci-dessus.
+
+---
+
+## Étape 12 — Copier les données de l'ancienne carte SD
+
+Depuis ton PC, copier les fichiers importants de l'ancienne carte :
+
+```bash
+# Monter l'ancienne carte SD sur le PC (remplacer /dev/sdX)
+# ou utiliser un lecteur de carte
+
+# Cadres (frames) photobooth
+rsync -av /mnt/old-sd/home/pi/photobooth-data/userdata/ \
+ pi@:/home/pi/photobooth-data/userdata/
+
+# Script d'impression
+rsync -av /mnt/old-sd/home/pi/photobooth-data/script/ \
+ pi@:/home/pi/photobooth-data/script/
+
+# Config photobooth-app (actions, frames configurées)
+rsync -av /mnt/old-sd/home/pi/.config/photobooth-app/ \
+ pi@:/home/pi/.config/photobooth-app/
+# ⚠️ Après cette copie, vérifier que plugin_commander.json pointe bien vers :8090 (pas :1880)
+
+# Config JH Photomaton (settings.yaml + base SQLite)
+rsync -av /mnt/old-sd/home/pi/jh-photomaton/config/settings.yaml \
+ pi@:/home/pi/jh-photomaton/config/
+rsync -av /mnt/old-sd/home/pi/jh-photomaton/data/ \
+ pi@:/home/pi/jh-photomaton/data/
+```
+
+---
+
+## Étape 13 — Vérification finale
+
+```bash
+# Tous les services actifs ?
+sudo systemctl status photobooth-app jh-photomaton cups
+
+# Logs JH Photomaton
+sudo journalctl -u jh-photomaton -f
+
+# Tests API rapides
+curl http://localhost:8090/api/event # événement en cours
+curl http://localhost:8090/api/system/stats # CPU/RAM/temp
+
+# Test LED
+curl -X POST "http://localhost:8090/api/leds/play?effect=capture"
+
+# Test webhook photobooth (simule une photo prise)
+curl "http://localhost:8090/api/webhook/photobooth?event_key=capture"
+curl "http://localhost:8090/api/webhook/photobooth?event_key=finished"
+
+# Imprimantes disponibles
+lpstat -p
+```
+
+Interface admin : `http://:8090/admin`
+
+---
+
+## Résumé des ports
+
+| Service | Port | Accès |
+|---------|------|-------|
+| JH Photomaton (admin) | 8090 | `http://admin.lessapinsduweb.com` ou `http://:8090/admin` |
+| photobooth-app (kiosque) | 8000 | `http://photomaton.lessapinsduweb.com` |
+| CUPS (imprimantes) | 631 | `http://:631` (réseau local uniquement) |
+| Zoraxy (proxy admin) | 8888 | `http://192.168.4.1:8888` |
+| WiFi hotspot | — | SSID `Photomaton-LSDW` — IP Pi : `192.168.4.1` |
+
+---
+
+## Points d'attention post-installation
+
+1. **plugin_commander.json** — vérifier que la URL pointe vers `:8090` (pas l'ancien Node-RED `:1880`)
+2. **share_command dans photobooth-app** — même chose, changer `:1880` → `:8090/api/print/request`
+3. **Nom des imprimantes CUPS** — doit correspondre exactement à ce qui est dans `settings.yaml`
+4. **GPIO18 / audio** — `dtparam=audio=off` dans `/boot/firmware/config.txt` est obligatoire pour les LEDs
+5. **Couleur du flash** — le réglage warm white `[255, 200, 80]` est dans `settings.yaml` → ajustable depuis l'admin dans Réglages → Flash photo
+6. **Zoraxy catch-all** — ne pas oublier la règle `*` → redirect `photomaton.lessapinsduweb.com`, c'est elle qui fait le portail captif
+7. **DAEMON_CONF hostapd** — sans cette ligne dans `/etc/default/hostapd`, hostapd démarre mais ignore la config (SSID invisible)
+
+---
+
+## CI/CD (déploiement automatique depuis Gitea)
+
+Voir `docs/CI-CD-SETUP.md` pour la configuration complète du pipeline Gitea Actions.
+
+En résumé :
+```bash
+# Sur le Pi — générer la clé SSH pour le CI
+ssh-keygen -t ed25519 -C "gitea-cicd" -f ~/.ssh/gitea_deploy -N ""
+cat ~/.ssh/gitea_deploy.pub >> ~/.ssh/authorized_keys
+
+# Installer la règle sudoers
+sudo cp /home/pi/jh-photomaton/scripts/sudoers-jh-photomaton /etc/sudoers.d/jh-photomaton
+sudo chmod 440 /etc/sudoers.d/jh-photomaton
+```
+
+Puis dans Gitea → Settings → Secrets : ajouter `PI_SSH_HOST`, `PI_SSH_USER`, `PI_SSH_KEY`.
diff --git a/frontend/templates/admin/dashboard.html b/frontend/templates/admin/dashboard.html
index c36cf35..235c391 100644
--- a/frontend/templates/admin/dashboard.html
+++ b/frontend/templates/admin/dashboard.html
@@ -6,6 +6,7 @@
Galerie admin
Impression
Actions
+Réglages
Galerie publique
Déconnexion
{% endblock %}
@@ -53,6 +54,28 @@
+
+
+
🎫 Événement en cours
+
+
+
+
+
+
+
+ 📸 — photos
+ ⬇️ — téléchargements
+ 🖨 — impressions
+
+
+
+
+
@@ -274,8 +297,37 @@ function onWsMessage(msg) {
}
}
+// ── Événement ─────────────────────────────────────────────────────────────────
+async function loadEvent() {
+ try {
+ const e = await api('GET', '/api/event');
+ document.getElementById('event-name-input').value = e.name || '';
+ const s = e.stats || {};
+ document.getElementById('ev-photos').textContent = s.photos_taken ?? '0';
+ document.getElementById('ev-downloads').textContent = s.downloads ?? '0';
+ document.getElementById('ev-prints').textContent = s.prints_done ?? '0';
+ document.getElementById('event-slug-display').textContent =
+ `Fichiers : ${e.slug}_By_LSDW_YYYY-MM-DD.jpg · Démarré le ${
+ e.started_at ? new Date(e.started_at * 1000).toLocaleDateString('fr-FR') : '—'
+ }`;
+ } catch(err) { console.warn('Événement:', err); }
+}
+
+async function saveEvent(isNew) {
+ const name = document.getElementById('event-name-input').value.trim();
+ if (!name) { showToast('Entrez un nom d\'événement', 'error'); return; }
+ if (isNew && !confirm(`Terminer l'événement actuel et démarrer "${name}" ?\n\nLes statistiques de l'ancien événement seront archivées.`)) return;
+ try {
+ const r = await api('PUT', '/api/event', {name, new_event: isNew});
+ showToast(isNew ? `↻ Nouvel événement : ${r.name}` : `✅ Événement : ${r.name}`, 'success');
+ loadEvent();
+ } catch(err) { showToast('Erreur sauvegarde événement', 'error'); }
+}
+
// ── Init ──────────────────────────────────────────────────────────────────────
loadQueue();
+loadEvent();
setInterval(loadQueue, 15000);
+setInterval(loadEvent, 60000);
{% endblock %}
diff --git a/frontend/templates/admin/gallery.html b/frontend/templates/admin/gallery.html
index 7b7e126..c737613 100644
--- a/frontend/templates/admin/gallery.html
+++ b/frontend/templates/admin/gallery.html
@@ -6,45 +6,199 @@
Galerie admin
Impression
Actions
+
Réglages
Galerie publique
Déconnexion
{% endblock %}
+{% block head %}
+
+{% endblock %}
+
{% block content %}
-
-
-
Galerie — Administration
-