diff --git a/backend/destinations.py b/backend/destinations.py index 8670ca3..4108d91 100644 --- a/backend/destinations.py +++ b/backend/destinations.py @@ -25,18 +25,17 @@ def distribuer_photo(chemin_photo: Path, imprimee: bool = False): daemon=True, ).start() - # Verifier si on sauvegarde tout ou seulement les imprimees + sous_dossier = None if not dest.get("sauvegarder_tout", True) and not imprimee: - log.info(f"Photo non imprimee, pas de distribution : {chemin_photo.name}") - return + sous_dossier = "non_imprimees" # Cle USB if dest.get("cle_usb", False): - copier_usb(chemin_photo, dest.get("chemin_usb", "/media/usb")) + copier_usb(chemin_photo, dest.get("chemin_usb", "/media/usb"), sous_dossier) # FTP if dest.get("ftp", False): - envoyer_ftp(chemin_photo, dest) + envoyer_ftp(chemin_photo, dest, sous_dossier) # Incrementer le compteur compteur = config.get("compteur", {}) @@ -45,7 +44,7 @@ def distribuer_photo(chemin_photo: Path, imprimee: bool = False): mettre_a_jour_config({"compteur": compteur}) -def copier_usb(chemin_photo: Path, chemin_usb: str): +def copier_usb(chemin_photo: Path, chemin_usb: str, sous_dossier: str | None = None): """Copie une photo sur la cle USB.""" dossier_usb = Path(chemin_usb) if not dossier_usb.exists(): @@ -53,24 +52,28 @@ def copier_usb(chemin_photo: Path, chemin_usb: str): return False dossier_dest = dossier_usb / "photobooth" - dossier_dest.mkdir(exist_ok=True) + if sous_dossier: + dossier_dest = dossier_dest / sous_dossier + dossier_dest.mkdir(parents=True, exist_ok=True) try: shutil.copy2(chemin_photo, dossier_dest / chemin_photo.name) - log.info(f"Photo copiee sur USB : {chemin_photo.name}") + log.info(f"Photo copiee sur USB : {chemin_photo.name} ({sous_dossier or 'root'})") return True except OSError as e: log.error(f"Erreur copie USB : {e}") return False -def envoyer_ftp(chemin_photo: Path, config_dest: dict): +def envoyer_ftp(chemin_photo: Path, config_dest: dict, sous_dossier: str | None = None): """Envoie une photo par FTP.""" host = config_dest.get("ftp_host", "") port = config_dest.get("ftp_port", 21) user = config_dest.get("ftp_user", "") password = config_dest.get("ftp_password", "") chemin_distant = config_dest.get("ftp_chemin", "/photobooth") + if sous_dossier: + chemin_distant = f"{chemin_distant}/{sous_dossier}" if not host: log.warning("FTP non configure") diff --git a/backend/effects.py b/backend/effects.py index 9a14d04..b230ae1 100644 --- a/backend/effects.py +++ b/backend/effects.py @@ -121,13 +121,24 @@ def chroma_key(chemin_photo: Path, nom_fond: str | None = None, return chemin_export -def appliquer_cadre_impression(img: Image.Image, format_papier: str, nom_cadre: str) -> Image.Image: +def appliquer_cadre_impression(img: Image.Image, format_papier: str, nom_cadre: str, event_id: str | None = None) -> Image.Image: """Composite un cadre PNG par-dessus l'image assemblée.""" - chemin = DOSSIER_CADRES / format_papier / nom_cadre + chemin = None + if event_id: + from backend.evenements import DOSSIER_EVENEMENTS + chemin_event = DOSSIER_EVENEMENTS / event_id / "cadres" / format_papier / nom_cadre + if chemin_event.exists(): + chemin = chemin_event + if chemin is None: + chemin = DOSSIER_CADRES / format_papier / nom_cadre if not chemin.exists(): log.warning(f"Cadre introuvable : {chemin}") return img cadre = Image.open(chemin).convert("RGBA") + img_portrait = img.height > img.width + cadre_portrait = cadre.height > cadre.width + if img_portrait != cadre_portrait: + cadre = cadre.rotate(90, expand=True) if cadre.size != img.size: cadre = cadre.resize(img.size, Image.LANCZOS) base = img.convert("RGBA") diff --git a/backend/evenements.py b/backend/evenements.py new file mode 100644 index 0000000..bc2eca6 --- /dev/null +++ b/backend/evenements.py @@ -0,0 +1,148 @@ +import json +import logging +import shutil +import uuid +from pathlib import Path + +from backend.config import RACINE, DOSSIER_CADRES, FORMATS_CADRES, charger_config, mettre_a_jour_config + +log = logging.getLogger("photobooth.evenements") + +DOSSIER_EVENEMENTS = RACINE / "data" / "evenements" +DOSSIER_EVENEMENTS.mkdir(parents=True, exist_ok=True) + + +def _chemin_event(event_id: str) -> Path: + return DOSSIER_EVENEMENTS / f"{event_id}.json" + + +def _chemin_cadres_event(event_id: str) -> Path: + d = DOSSIER_EVENEMENTS / event_id / "cadres" + d.mkdir(parents=True, exist_ok=True) + return d + + +def lister_evenements() -> list[dict]: + events = [] + config = charger_config() + actif_id = config.get("evenement", {}).get("event_id") + for f in sorted(DOSSIER_EVENEMENTS.glob("*.json")): + try: + data = json.loads(f.read_text("utf-8")) + data["actif"] = (data.get("id") == actif_id) + events.append(data) + except (json.JSONDecodeError, KeyError): + continue + return events + + +def creer_evenement(nom: str, **kwargs) -> dict: + event_id = uuid.uuid4().hex[:8] + event = { + "id": event_id, + "nom": nom, + "theme": kwargs.get("theme", "base"), + "couleur_primaire": kwargs.get("couleur_primaire", "#e91e63"), + "couleur_secondaire": kwargs.get("couleur_secondaire", "#ffffff"), + "media_accueil": kwargs.get("media_accueil"), + "cadres": {}, + } + _chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8") + log.info(f"Evenement cree : {nom} ({event_id})") + return event + + +def obtenir_evenement(event_id: str) -> dict | None: + chemin = _chemin_event(event_id) + if not chemin.exists(): + return None + return json.loads(chemin.read_text("utf-8")) + + +def modifier_evenement(event_id: str, modifications: dict) -> dict | None: + event = obtenir_evenement(event_id) + if not event: + return None + for k, v in modifications.items(): + if k != "id": + event[k] = v + _chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8") + return event + + +def supprimer_evenement(event_id: str) -> bool: + chemin = _chemin_event(event_id) + if not chemin.exists(): + return False + chemin.unlink() + dossier_cadres = DOSSIER_EVENEMENTS / event_id + if dossier_cadres.exists(): + shutil.rmtree(dossier_cadres) + config = charger_config() + if config.get("evenement", {}).get("event_id") == event_id: + mettre_a_jour_config({"evenement": {"event_id": None}}) + log.info(f"Evenement supprime : {event_id}") + return True + + +def activer_evenement(event_id: str) -> dict | None: + event = obtenir_evenement(event_id) + if not event: + return None + mettre_a_jour_config({ + "evenement": { + "event_id": event_id, + "nom": event["nom"], + "theme": event.get("theme", "base"), + "couleur_primaire": event.get("couleur_primaire", "#e91e63"), + "couleur_secondaire": event.get("couleur_secondaire", "#ffffff"), + "media_accueil": event.get("media_accueil"), + } + }) + log.info(f"Evenement active : {event['nom']} ({event_id})") + return event + + +def lister_cadres_event(event_id: str, format_papier: str) -> list[str]: + dossier = _chemin_cadres_event(event_id) / format_papier + if not dossier.exists(): + return [] + return sorted(f.name for f in dossier.iterdir() if f.suffix.lower() == ".png") + + +def config_cadre_event(event_id: str) -> dict: + """Retourne la config cadres de l'evenement : {format: {mode, cadre}} + mode: 'disponible' (choix user), 'impose' (force), 'defaut' (pre-selectionne) + """ + event = obtenir_evenement(event_id) + if not event: + return {} + return event.get("cadres", {}) + + +def set_cadre_event(event_id: str, format_papier: str, mode: str, cadre: str | None) -> bool: + """mode: 'aucun', 'disponible', 'impose', 'defaut'""" + event = obtenir_evenement(event_id) + if not event: + return False + if "cadres" not in event: + event["cadres"] = {} + event["cadres"][format_papier] = {"mode": mode, "cadre": cadre} + _chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8") + return True + + +def cadre_actif_pour_impression(format_papier: str) -> tuple[str | None, str]: + """Retourne (nom_cadre, mode) en tenant compte de l'evenement actif. + mode: 'aucun', 'disponible', 'impose', 'defaut' + """ + config = charger_config() + event_id = config.get("evenement", {}).get("event_id") + if not event_id: + return None, "aucun" + event = obtenir_evenement(event_id) + if not event: + return None, "aucun" + cadres = event.get("cadres", {}) + fmt_conf = cadres.get(format_papier, {}) + return fmt_conf.get("cadre"), fmt_conf.get("mode", "aucun") diff --git a/backend/main.py b/backend/main.py index 4613765..ff8aef5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,6 +30,12 @@ from backend.destinations import distribuer_photo, detecter_usb, compteur_restan from backend.printer import lister_imprimantes, imprimer from backend.mailer import envoyer_photo, charger_emails_historique, effacer_emails_historique, ajouter_au_spool, taille_spool, tache_spool_demarrage from backend.qrcode_gen import generer_qr, qr_galerie +from backend.evenements import ( + lister_evenements, creer_evenement, obtenir_evenement, + modifier_evenement, supprimer_evenement, activer_evenement, + lister_cadres_event, set_cadre_event, cadre_actif_pour_impression, +) +from backend.evenements import DOSSIER_EVENEMENTS logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") log = logging.getLogger("photobooth") @@ -890,6 +896,103 @@ async def api_booth_imprimer_qr(): return {"succes": ok, "chemin": f"/data/exports/flyer_qr_booth.jpg"} +# --- API Evenements --- + +@app.get("/api/evenements") +async def api_lister_evenements(): + return lister_evenements() + + +@app.post("/api/evenements") +async def api_creer_evenement(donnees: dict): + nom = donnees.get("nom", "").strip() + if not nom: + return JSONResponse({"erreur": "Nom requis"}, status_code=400) + event = creer_evenement( + nom, + theme=donnees.get("theme", "base"), + couleur_primaire=donnees.get("couleur_primaire", "#e91e63"), + couleur_secondaire=donnees.get("couleur_secondaire", "#ffffff"), + media_accueil=donnees.get("media_accueil"), + ) + return event + + +@app.get("/api/evenements/{event_id}") +async def api_obtenir_evenement(event_id: str): + event = obtenir_evenement(event_id) + if not event: + return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404) + return event + + +@app.put("/api/evenements/{event_id}") +async def api_modifier_evenement(event_id: str, donnees: dict): + event = modifier_evenement(event_id, donnees) + if not event: + return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404) + return event + + +@app.delete("/api/evenements/{event_id}") +async def api_supprimer_evenement(event_id: str): + ok = supprimer_evenement(event_id) + if not ok: + return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404) + return {"succes": True} + + +@app.post("/api/evenements/{event_id}/activer") +async def api_activer_evenement(event_id: str): + event = activer_evenement(event_id) + if not event: + return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404) + return event + + +@app.get("/api/evenements/{event_id}/cadres/{format_papier}") +async def api_cadres_event(event_id: str, format_papier: str): + cadres = lister_cadres_event(event_id, format_papier) + event = obtenir_evenement(event_id) + conf = (event or {}).get("cadres", {}).get(format_papier, {}) + return { + "disponibles": cadres, + "mode": conf.get("mode", "aucun"), + "cadre": conf.get("cadre"), + } + + +@app.post("/api/evenements/{event_id}/cadres/{format_papier}") +async def api_set_cadre_event(event_id: str, format_papier: str, donnees: dict): + mode = donnees.get("mode", "aucun") + cadre = donnees.get("cadre") + set_cadre_event(event_id, format_papier, mode, cadre) + return {"succes": True} + + +@app.post("/api/evenements/{event_id}/upload-cadre/{format_papier}") +async def api_upload_cadre_event(event_id: str, format_papier: str, fichier: UploadFile = File(...)): + if format_papier not in FORMATS_CADRES: + return JSONResponse({"erreur": "Format inconnu"}, status_code=400) + if not fichier.filename.lower().endswith(".png"): + return JSONResponse({"erreur": "Seuls les PNG sont acceptes"}, status_code=400) + dossier = DOSSIER_EVENEMENTS / event_id / "cadres" / format_papier + dossier.mkdir(parents=True, exist_ok=True) + chemin = dossier / fichier.filename + with open(chemin, "wb") as f: + f.write(await fichier.read()) + return {"nom": fichier.filename, "format": format_papier} + + +@app.delete("/api/evenements/{event_id}/cadres/{format_papier}/{nom}") +async def api_supprimer_cadre_event(event_id: str, format_papier: str, nom: str): + chemin = DOSSIER_EVENEMENTS / event_id / "cadres" / format_papier / nom + if not chemin.exists(): + return JSONResponse({"erreur": "Fichier introuvable"}, status_code=404) + chemin.unlink() + return {"succes": True} + + # --- API Galerie --- @app.get("/api/galerie") @@ -977,7 +1080,16 @@ async def api_cadres_impression_list(format_papier: str): return JSONResponse({"erreur": "Format inconnu"}, status_code=400) config = charger_config() actif = config.get("impression", {}).get("cadres_actifs", {}).get(format_papier) - return {"format": format_papier, "disponibles": lister_cadres(format_papier), "actif": actif} + result = {"format": format_papier, "disponibles": lister_cadres(format_papier), "actif": actif} + event_id = config.get("evenement", {}).get("event_id") + if event_id: + ev_cadres = lister_cadres_event(event_id, format_papier) + ev_cadre, ev_mode = cadre_actif_pour_impression(format_papier) + result["event_cadres"] = ev_cadres + result["event_mode"] = ev_mode + result["event_cadre"] = ev_cadre + result["event_id"] = event_id + return result @app.post("/api/cadres-impression/{format_papier}/actif") diff --git a/backend/printer.py b/backend/printer.py index 806ed18..27b8a27 100644 --- a/backend/printer.py +++ b/backend/printer.py @@ -77,7 +77,7 @@ def _crop_to(img, largeur: int, hauteur: int): def _preparer_image(chemin: Path, largeur: int, hauteur: int, format_papier: str | None = None, cadre: str | None = None, - orientation: str = "paysage") -> Path: + orientation: str = "paysage", event_id: str | None = None) -> Path: """ Pivote si nécessaire, recadre aux dimensions exactes (cover crop), applique le cadre, sauvegarde en temp. @@ -101,7 +101,7 @@ def _preparer_image(chemin: Path, largeur: int, hauteur: int, img = _crop_to(img, largeur, hauteur) if cadre and format_papier: - img = appliquer_cadre_impression(img, format_papier, cadre) + img = appliquer_cadre_impression(img, format_papier, cadre, event_id=event_id) tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) img.save(tmp.name, "JPEG", quality=95, dpi=(300, 300)) @@ -221,11 +221,20 @@ def imprimer( cadre = cadre_override if cadre_override is not None else conf_imp.get("cadres_actifs", {}).get(fmt_base) orientation = conf_imp.get("orientations", {}).get(fmt_base, "portrait") + event_id = config.get("evenement", {}).get("event_id") + if event_id and cadre_override is None: + from backend.evenements import cadre_actif_pour_impression + ev_cadre, ev_mode = cadre_actif_pour_impression(fmt_base) + if ev_mode == "impose" and ev_cadre: + cadre = ev_cadre + elif ev_mode == "defaut" and ev_cadre and not cadre: + cadre = ev_cadre + if not chemin_photo.exists(): log.error(f"Fichier introuvable : {chemin_photo}") return {"succes": False, "erreur": ERREUR_FICHIER, "message": "Fichier photo introuvable"} - chemin_print = _preparer_image(chemin_photo, largeur, hauteur, fmt_base, cadre, orientation) + chemin_print = _preparer_image(chemin_photo, largeur, hauteur, fmt_base, cadre, orientation, event_id=event_id) tmp_a_supprimer = [chemin_print] if chemin_print != chemin_photo else [] px_page = fmt.get("px_page") diff --git a/frontend/css/style.css b/frontend/css/style.css index 64ed904..4ca3149 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1332,6 +1332,76 @@ h3 { /* --- Fin cadres impression --- */ +/* --- Evenements admin --- */ +.event-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.8rem; + margin-bottom: 0.4rem; + background: rgba(255,255,255,0.04); + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.08); +} +.event-item.event-actif { + border-color: var(--primaire); + background: rgba(233,30,99,0.08); +} +.event-nom { font-weight: 500; } +.event-actions { display: flex; gap: 0.3rem; align-items: center; } +.badge-actif { + background: var(--primaire); + color: #fff; + padding: 0.2rem 0.6rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; +} +.cadre-event-section { margin-bottom: 1.2rem; } +.cadres-event-grille { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin: 0.5rem 0; +} +.cadre-event-item { + position: relative; + width: 90px; + text-align: center; + cursor: pointer; + padding: 0.3rem; + border: 2px solid transparent; + border-radius: 8px; +} +.cadre-event-item.cadre-selectionne { + border-color: var(--primaire); + background: rgba(233,30,99,0.1); +} +.cadre-event-item img { + width: 80px; + height: 80px; + object-fit: contain; + border-radius: 4px; +} +.cadre-event-item span { + display: block; + font-size: 0.7rem; + margin-top: 0.2rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cadre-event-item .btn-icone { + position: absolute; + top: -4px; + right: -4px; + width: 20px; + height: 20px; + font-size: 0.6rem; + line-height: 20px; + padding: 0; +} + /* --- Choix cadre avant capture --- */ #ecran-cadre-choix { display: flex; diff --git a/frontend/index.html b/frontend/index.html index 15a26c9..2aa235e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@ - +
@@ -578,37 +578,51 @@