"""API galerie admin — impression et suppression de photos.""" import logging from datetime import datetime from pathlib import Path from fastapi import APIRouter, Request, Query from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) router = APIRouter() # ── Helpers ─────────────────────────────────────────────────────────────────── def _is_image(item: dict) -> bool: t = item.get("type", item.get("mediaitem_type", "image")) return str(t).lower() in ("image", "still", "photo") 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) -> Path | None: """Cherche un fichier image correspondant à l'identifiant dans le répertoire media.""" base = Path(media_dir) if not base.exists(): return None for ext in (".jpg", ".jpeg", ".png"): f = base / f"{photo_id}{ext}" if f.exists(): return f 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), event_slug: str = Query(default=None), date_from: float = Query(default=None), date_to: float = Query(default=None), ): """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 cfg = request.app.state.config event_svc = getattr(request.app.state, "event_service", None) media_dir = Path(cfg.photobooth.media_dir) # 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)] # Enrichit chaque photo avec mtime depuis le disque # Indexe par stem ET par nom complet pour couvrir les deux formats d'id mtime_index: dict[str, float] = {} for media_dir_candidate in [ media_dir, Path("/home/pi/photobooth-data/media/processed_full"), Path("/home/pi/photobooth-data/media"), ]: if media_dir_candidate.exists(): for f in media_dir_candidate.iterdir(): if f.is_file(): mt = f.stat().st_mtime mtime_index[f.name] = mt # uuid.jpg mtime_index[f.stem] = mt # uuid break for p in photos: pid = _get_id(p) p["photo_id"] = pid stem = pid.rsplit(".", 1)[0] if "." in pid else pid mt = mtime_index.get(pid) or mtime_index.get(stem) or 0.0 p["mtime"] = mt p["date_iso"] = datetime.fromtimestamp(mt).strftime("%Y-%m-%d") if mt else "" p["date_label"] = datetime.fromtimestamp(mt).strftime("%d/%m/%Y %H:%M") if mt else "" # Filtre par événement if event_slug and event_svc: ev = await event_svc.get_event_by_slug(event_slug) if ev: t0 = ev["started_at"] or 0.0 t1 = ev["ended_at"] or datetime.now().timestamp() photos = [p for p in photos if t0 <= p["mtime"] <= t1] # Filtre par plage de dates if date_from is not None: photos = [p for p in photos if p["mtime"] >= date_from] if date_to is not None: photos = [p for p in photos if p["mtime"] <= date_to] total = len(photos) start = (page - 1) * limit page_photos = photos[start:start + limit] # Construit les URLs for p in page_photos: pid = p["photo_id"] p["full_url"] = pb.media_url(pid) p["thumb_url"] = pb.thumbnail_url(pid) p["download_url"] = f"/api/gallery/download/{pid}" # Croise avec la file d'impression en attente 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}