import ftplib import json import logging import shutil import threading from pathlib import Path from urllib.request import Request, urlopen from urllib.error import URLError from backend.config import charger_config, mettre_a_jour_config, RACINE log = logging.getLogger("photobooth.destinations") FICHIER_BOOTH_SPOOL = RACINE / "data" / "booth_spool.json" _booth_spool_lock = threading.Lock() def distribuer_photo(chemin_photo: Path, imprimee: bool = False, copies: int = 1, format_papier: str | None = None): """Copie la photo vers toutes les destinations activees.""" config = charger_config() dest = config.get("destinations", {}) # Galerie live (booth) — toujours envoyer, imprimee ou non booth = config.get("booth", {}) if booth.get("actif", False): threading.Thread( target=envoyer_booth, args=(chemin_photo, booth), daemon=True, ).start() sous_dossier = None if not dest.get("sauvegarder_tout", True) and not imprimee: sous_dossier = "non_imprimees" # Cle USB if dest.get("cle_usb", False): copier_usb(chemin_photo, dest.get("chemin_usb", "/media/usb"), sous_dossier) # FTP if dest.get("ftp", False): envoyer_ftp(chemin_photo, dest, sous_dossier) # Incrementer le compteur (feuilles 10x15 consommees) if imprimee: compteur = config.get("compteur", {}) if compteur.get("actif", False): compteur["photos_prises"] = compteur.get("photos_prises", 0) + copies mettre_a_jour_config({"compteur": compteur}) maj_consommables(config, copies, format_papier) 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(): log.warning(f"Cle USB non trouvee : {chemin_usb}") return False dossier_dest = dossier_usb / "photobooth" 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} ({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, 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") return False try: with ftplib.FTP() as ftp: ftp.connect(host, port, timeout=10) ftp.login(user, password) # Creer le dossier si necessaire try: ftp.mkd(chemin_distant) except ftplib.error_perm: pass ftp.cwd(chemin_distant) with open(chemin_photo, "rb") as f: ftp.storbinary(f"STOR {chemin_photo.name}", f) log.info(f"Photo envoyee par FTP : {chemin_photo.name}") return True except (ftplib.all_errors, OSError) as e: log.error(f"Erreur FTP : {e}") return False def _upload_booth(url: str, api_key: str, event_id: str, chemin_photo: Path) -> bool: boundary = "----BoothUpload" filename = chemin_photo.name with open(chemin_photo, "rb") as f: file_data = f.read() body = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="photo"; filename="{filename}"\r\n' f"Content-Type: image/jpeg\r\n\r\n" ).encode() + file_data + f"\r\n--{boundary}--\r\n".encode() req = Request(f"{url}/admin/gallery/{event_id}/upload", data=body, method="POST") req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") req.add_header("X-Api-Key", api_key) with urlopen(req, timeout=10) as resp: return resp.status == 200 def envoyer_booth(chemin_photo: Path, config_booth: dict): """Envoie une photo vers la galerie live booth. En cas d'echec, met en spool.""" url = config_booth.get("url", "").rstrip("/") url_tunnel = config_booth.get("url_tunnel", "").rstrip("/") api_key = config_booth.get("api_key", "") config = charger_config() event_id = config.get("evenement", {}).get("event_id") or config_booth.get("event_id", "default") if not url and not url_tunnel: log.warning("Booth non configure (URL manquante)") return False for tentative_url in [u for u in (url_tunnel, url) if u]: try: if _upload_booth(tentative_url, api_key, event_id, chemin_photo): log.info(f"Photo envoyee au booth via {tentative_url} : {chemin_photo.name}") return True log.error(f"Booth erreur HTTP via {tentative_url}") except (URLError, OSError) as e: log.warning(f"Echec envoi booth via {tentative_url} : {e}") _ajouter_booth_spool(str(chemin_photo)) return False # --- Spool galerie booth --- def _ajouter_booth_spool(chemin: str): with _booth_spool_lock: spool = _charger_booth_spool() if chemin not in spool: spool.append(chemin) _sauver_booth_spool(spool) log.info(f"Photo mise en spool galerie ({len(spool)} en attente)") def _charger_booth_spool() -> list: if not FICHIER_BOOTH_SPOOL.exists(): return [] try: with open(FICHIER_BOOTH_SPOOL, "r") as f: return json.load(f) except (json.JSONDecodeError, OSError): return [] def _sauver_booth_spool(spool: list): try: with open(FICHIER_BOOTH_SPOOL, "w") as f: json.dump(spool, f) except OSError: pass def taille_booth_spool() -> int: with _booth_spool_lock: return len(_charger_booth_spool()) def traiter_booth_spool() -> int: """Retente l'envoi des photos en spool. Retourne le nombre envoyees.""" with _booth_spool_lock: spool = _charger_booth_spool() if not spool: return 0 config = charger_config() booth = config.get("booth", {}) if not booth.get("actif", False): return 0 envoyes = 0 restants = [] for chemin_str in spool: chemin = Path(chemin_str) if not chemin.exists(): log.warning(f"Spool booth: photo introuvable {chemin}, ignoree") continue url = booth.get("url", "").rstrip("/") url_tunnel = booth.get("url_tunnel", "").rstrip("/") api_key = booth.get("api_key", "") event_id = config.get("evenement", {}).get("event_id") or booth.get("event_id", "default") ok = False for tentative_url in [u for u in (url_tunnel, url) if u]: try: if _upload_booth(tentative_url, api_key, event_id, chemin): log.info(f"Spool booth: {chemin.name} envoyee via {tentative_url}") ok = True break except (URLError, OSError): pass if ok: envoyes += 1 else: restants.append(chemin_str) break if envoyes > 0 or len(restants) < len(spool): idx = envoyes + len(restants) restants.extend(spool[idx:]) with _booth_spool_lock: _sauver_booth_spool(restants) log.info(f"Spool booth: {envoyes} envoyee(s), {len(restants)} en attente") return envoyes def recuperer_booth_password() -> dict: """Recupere le mot de passe et l'info de la session booth.""" config = charger_config() booth = config.get("booth", {}) url = booth.get("url", "").rstrip("/") url_tunnel = booth.get("url_tunnel", "").rstrip("/") api_key = booth.get("api_key", "") event_id = config.get("evenement", {}).get("event_id") or booth.get("event_id", "default") for tentative_url in [u for u in (url_tunnel, url) if u]: try: req = Request(f"{tentative_url}/admin/gallery/{event_id}/info") req.add_header("X-Api-Key", api_key) with urlopen(req, timeout=5) as resp: import json return json.loads(resp.read()) except (URLError, OSError) as e: log.warning(f"Echec recuperation booth info via {tentative_url} : {e}") return {"password": None} def detecter_usb() -> list[str]: """Detecte les cles USB montees (cherche dans /media, /mnt et /media//).""" usb_trouvees = [] candidats = [] for racine in [Path("/media"), Path("/mnt")]: if not racine.exists(): continue try: for niveau1 in racine.iterdir(): if not niveau1.is_dir(): continue if niveau1.is_mount(): candidats.append(niveau1) else: # /media// → descendre encore (udiskie) try: for niveau2 in niveau1.iterdir(): if niveau2.is_dir() and niveau2.is_mount(): candidats.append(niveau2) except PermissionError: pass except PermissionError: pass for chemin in candidats: # Exclure les partitions système try: result = __import__("subprocess").run( ["findmnt", "-no", "FSTYPE", str(chemin)], capture_output=True, text=True, timeout=3 ) fstype = result.stdout.strip() if fstype in ("vfat", "exfat", "ntfs", "ext4", "ext3", "hfsplus"): usb_trouvees.append(str(chemin)) except Exception: usb_trouvees.append(str(chemin)) return usb_trouvees def compteur_restant() -> dict: """Retourne l'etat du compteur, basé sur les consommables restants.""" config = charger_config() compteur = config.get("compteur", {}) conso = config.get("consommables", {}) prises = compteur.get("photos_prises", 0) papier_rest = max(0, conso.get("papier_capacite", 400) - conso.get("papier_utilise", 0)) ruban_rest = max(0, round(conso.get("ruban_capacite", 400) - conso.get("ruban_utilise", 0), 1)) restantes = int(min(papier_rest, ruban_rest)) limite_event = compteur.get("limite", 0) if compteur.get("actif") and limite_event > 0: restantes = min(restantes, max(0, limite_event - prises)) papier_cap = conso.get("papier_capacite", 400) ruban_cap = conso.get("ruban_capacite", 400) capacite_conso = int(min(papier_cap, ruban_cap)) return { "actif": compteur.get("actif", False), "limite": limite_event, "photos_prises": prises, "restantes": restantes, "capacite": capacite_conso, "papier_restant": papier_rest, "ruban_restant": int(ruban_rest), } def reset_compteur(): """Remet le compteur a zero.""" mettre_a_jour_config({"compteur": {"photos_prises": 0}}) # --- Suivi consommables --- RATIO_RUBAN_OPTIMAL = { "10x15": 0.5, "10x15-2up": 1.0, "15x20": 1.0, "15x20-2up": 1.0, } def maj_consommables(config: dict, copies: int, format_papier: str | None): """Met a jour le suivi consommables (papier, ruban, photos).""" conso = config.get("consommables", {}) fmt = format_papier or config.get("impression", {}).get("format", "15x20") rembobinage = config.get("impression", {}).get("rembobinage_ruban", False) is_2up = "-2up" in fmt feuilles = copies if not is_2up else (copies + 1) // 2 conso["papier_utilise"] = conso.get("papier_utilise", 0) + feuilles conso["photos_imprimees"] = conso.get("photos_imprimees", 0) + copies ratio_optimal = RATIO_RUBAN_OPTIMAL.get(fmt, 1.0) if rembobinage: ruban_consomme = feuilles * ratio_optimal else: ruban_consomme = feuilles * 1.0 conso["ruban_utilise"] = round(conso.get("ruban_utilise", 0) + ruban_consomme, 1) # Poses perdues = ruban avance inutilement (difference entre consomme et optimal) poses_perdues = feuilles * 1.0 - feuilles * ratio_optimal if not rembobinage else 0 conso["poses_perdues"] = round(conso.get("poses_perdues", 0) + poses_perdues, 1) mettre_a_jour_config({"consommables": conso}) def consommables_etat() -> dict: """Retourne l'etat des consommables.""" config = charger_config() conso = config.get("consommables", {}) papier_cap = conso.get("papier_capacite", 400) ruban_cap = conso.get("ruban_capacite", 400) papier_used = conso.get("papier_utilise", 0) ruban_used = conso.get("ruban_utilise", 0) photos = conso.get("photos_imprimees", 0) perdues = conso.get("poses_perdues", 0) return { "papier_capacite": papier_cap, "papier_utilise": papier_used, "papier_restant": max(0, papier_cap - papier_used), "ruban_capacite": ruban_cap, "ruban_utilise": ruban_used, "ruban_restant": max(0, round(ruban_cap - ruban_used, 1)), "photos_imprimees": photos, "poses_perdues": perdues, } def reset_consommables(quoi: str): """Reinitialise papier, ruban ou tout.""" config = charger_config() conso = config.get("consommables", {}) if quoi in ("papier", "tout"): conso["papier_utilise"] = 0 if quoi in ("ruban", "tout"): conso["ruban_utilise"] = 0 conso["poses_perdues"] = 0 if quoi == "tout": conso["photos_imprimees"] = 0 conso["poses_perdues"] = 0 mettre_a_jour_config({"consommables": conso})