diff --git a/backend/api/actions_api.py b/backend/api/actions_api.py index 57ef4b4..9866b22 100644 --- a/backend/api/actions_api.py +++ b/backend/api/actions_api.py @@ -1,110 +1,188 @@ -"""API de gestion des actions photobooth-app + mapping bouton.""" +"""API de gestion des actions photobooth-app + mapping bouton + presets.""" +import copy +import json import logging +import re +from datetime import datetime +from pathlib import Path + from fastapi import APIRouter, Request, Body from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) router = APIRouter() +# ── Action par défaut ──────────────────────────────────────────────────────── + +DEFAULT_ACTION: dict = { + "name": "Nouvelle action", + "jobcontrol": {"countdown_capture": 5.0}, + "processing": { + "remove_background": False, + "fill_background_enable": False, + "fill_background_color": "#ededed", + "img_background_enable": False, + "img_background_file": None, + "image_filter": "original", + "img_frame_enable": False, + "img_frame_file": None, + "texts_enable": False, + "texts": [], + }, + "trigger": { + "ui_trigger": { + "show_button": False, + "title": "Nouvelle action", + "icon": "Photo", + "use_custom_color": False, + "custom_color": "#016911", + }, + "keyboard_trigger": {"keycode": ""}, + "gpio_trigger": {"pin": "", "trigger_on": "pressed"}, + }, +} + + +def _presets_dir(request: Request) -> Path: + base = Path(request.app.state.config_service._path).parent.parent / "data" / "action_presets" + base.mkdir(parents=True, exist_ok=True) + return base + + +# ── Lecture ─────────────────────────────────────────────────────────────────── @router.get("/actions/photobooth") async def get_pb_actions(request: Request): - """Retourne les actions image de photobooth-app + le mapping bouton actuel.""" pb = request.app.state.photobooth_service cfg = request.app.state.config - pb_config = await pb.read_pb_config() actions = pb_config.get("actions", {}).get("image", []) - - # Enrichit avec l'index for i, action in enumerate(actions): action["_index"] = i - - return { - "actions": actions, - "button_mapping": cfg.button_actions, - } + return {"actions": actions, "button_mapping": cfg.button_actions} @router.get("/actions/assets") async def get_assets(request: Request): - """Liste les cadres et fonds disponibles dans userdata.""" pb = request.app.state.photobooth_service frames = await pb.list_userdata_frames() backgrounds = await pb.list_userdata_backgrounds() return {"frames": frames, "backgrounds": backgrounds} -@router.put("/actions/mapping") -async def update_button_mapping( - request: Request, - mapping: dict = Body(...), -): - """ - Met à jour le mapping clics → actions. - Body: { "1": {"label": "...", "photobooth_index": 0}, ... } - """ - config_svc = request.app.state.config_service +# ── CRUD actions ────────────────────────────────────────────────────────────── - # Validation basique - for k, v in mapping.items(): - if not isinstance(v, dict) or "photobooth_index" not in v: - return JSONResponse({"error": f"Format invalide pour clic {k}"}, status_code=400) +@router.post("/actions/photobooth") +async def create_action(request: Request, body: dict = Body(default={})): + """Crée une nouvelle action vide.""" + pb = request.app.state.photobooth_service + pb_config = await pb.read_pb_config() + actions = pb_config.setdefault("actions", {}).setdefault("image", []) + new_action = copy.deepcopy(DEFAULT_ACTION) + if body.get("name"): + new_action["name"] = body["name"] + actions.append(new_action) + await pb.write_pb_config(pb_config) + idx = len(actions) - 1 + logger.info("Nouvelle action créée: index %d, nom=%s", idx, new_action["name"]) + return {"ok": True, "index": idx, "action": new_action} - config_svc.save_button_actions(mapping) - request.app.state.config.button_actions = mapping - # Met aussi à jour le button_service - btn = request.app.state.button_service - btn._btn_actions = mapping +@router.post("/actions/photobooth/{index}/clone") +async def clone_action(request: Request, index: int): + """Clone une action (insérée juste après l'original).""" + pb = request.app.state.photobooth_service + pb_config = await pb.read_pb_config() + actions = pb_config.get("actions", {}).get("image", []) + if index < 0 or index >= len(actions): + return JSONResponse({"error": f"Index {index} invalide"}, status_code=400) + cloned = copy.deepcopy(actions[index]) + cloned.pop("_index", None) + cloned["name"] = f"{cloned.get('name', 'Action')} (copie)" + actions.insert(index + 1, cloned) + await pb.write_pb_config(pb_config) + logger.info("Action %d clonée → %d: %s", index, index + 1, cloned["name"]) + return {"ok": True, "index": index + 1, "action": cloned} - logger.info("Mapping bouton mis à jour: %s", mapping) - return {"ok": True, "mapping": mapping} + +@router.delete("/actions/photobooth/{index}") +async def delete_action(request: Request, index: int): + """Supprime une action.""" + pb = request.app.state.photobooth_service + pb_config = await pb.read_pb_config() + actions = pb_config.get("actions", {}).get("image", []) + if index < 0 or index >= len(actions): + return JSONResponse({"error": f"Index {index} invalide"}, status_code=400) + if len(actions) <= 1: + return JSONResponse({"error": "Impossible de supprimer la dernière action"}, status_code=400) + removed = actions.pop(index) + await pb.write_pb_config(pb_config) + logger.info("Action %d supprimée: %s", index, removed.get("name")) + return {"ok": True, "removed_name": removed.get("name")} + + +@router.put("/actions/photobooth/reorder") +async def reorder_actions(request: Request, order: list = Body(...)): + """Réordonne les actions. Body: [ancien_idx_0, ancien_idx_1, …]""" + pb = request.app.state.photobooth_service + pb_config = await pb.read_pb_config() + actions = pb_config.get("actions", {}).get("image", []) + if sorted(order) != list(range(len(actions))): + return JSONResponse({"error": "Ordre invalide"}, status_code=400) + pb_config["actions"]["image"] = [actions[i] for i in order] + await pb.write_pb_config(pb_config) + logger.info("Actions réordonnées: %s", order) + return {"ok": True, "order": order} @router.put("/actions/photobooth/{index}") -async def update_pb_action( - request: Request, - index: int, - updates: dict = Body(...), -): - """ - Met à jour une action image de photobooth-app (cadre, fond, countdown, etc.) - updates peut contenir: countdown_capture, img_frame_file, img_background_file, - remove_background, image_filter, name +async def update_pb_action(request: Request, index: int, updates: dict = Body(...)): + """Met à jour une action (tous les champs sauf GPIO/keyboard trigger). + + Champs plats acceptés: + name, countdown_capture, + img_frame_enable, img_frame_file, + img_background_enable, img_background_file, + fill_background_enable, fill_background_color, + remove_background, image_filter, texts_enable, + ui_show_button, ui_title, ui_icon, ui_use_custom_color, ui_custom_color """ pb = request.app.state.photobooth_service pb_config = await pb.read_pb_config() actions = pb_config.get("actions", {}).get("image", []) - if index < 0 or index >= len(actions): - return JSONResponse({"error": f"Index {index} invalide (max: {len(actions)-1})"}, status_code=400) + return JSONResponse({"error": f"Index {index} invalide"}, status_code=400) action = actions[index] - # Mise à jour des champs autorisés - allowed_root = {"name"} - allowed_processing = {"remove_background", "img_frame_file", "img_background_file", - "image_filter", "fill_background_enable", "fill_background_color", - "img_background_enable", "texts_enable"} - allowed_jobcontrol = {"countdown_capture"} + if "name" in updates: + action["name"] = updates["name"] - for key, value in updates.items(): - if key in allowed_root: - action[key] = value - elif key in allowed_processing: - action.setdefault("processing", {})[key] = value - elif key in allowed_jobcontrol: - action.setdefault("jobcontrol", {})[key] = value + if "countdown_capture" in updates: + action.setdefault("jobcontrol", {})["countdown_capture"] = float(updates["countdown_capture"]) + + proc = action.setdefault("processing", {}) + for key in ("img_frame_enable", "img_frame_file", + "img_background_enable", "img_background_file", + "fill_background_enable", "fill_background_color", + "remove_background", "image_filter", "texts_enable"): + if key in updates: + proc[key] = updates[key] + + ui = action.setdefault("trigger", {}).setdefault("ui_trigger", {}) + for key in ("show_button", "title", "icon", "use_custom_color", "custom_color"): + if f"ui_{key}" in updates: + ui[key] = updates[f"ui_{key}"] pb_config["actions"]["image"][index] = action await pb.write_pb_config(pb_config) - logger.info("Action %d mise à jour: %s", index, list(updates.keys())) return {"ok": True, "index": index, "action": action} +# ── Déclenchement ───────────────────────────────────────────────────────────── + @router.post("/actions/trigger/{index}") async def trigger_action(request: Request, index: int): """Déclenche une action directement (test).""" @@ -119,3 +197,101 @@ async def trigger_action(request: Request, index: int): led.play("error") btn.relay_on() return JSONResponse({"error": str(e)}, status_code=500) + + +# ── Mapping bouton ──────────────────────────────────────────────────────────── + +@router.put("/actions/mapping") +async def update_button_mapping(request: Request, mapping: dict = Body(...)): + """Body: { "1": {"label": "…", "photobooth_index": 0}, … }""" + config_svc = request.app.state.config_service + for k, v in mapping.items(): + if not isinstance(v, dict) or "photobooth_index" not in v: + return JSONResponse({"error": f"Format invalide pour clic {k}"}, status_code=400) + config_svc.save_button_actions(mapping) + request.app.state.config.button_actions = mapping + request.app.state.button_service._btn_actions = mapping + logger.info("Mapping bouton mis à jour: %s", mapping) + return {"ok": True, "mapping": mapping} + + +# ── Presets ─────────────────────────────────────────────────────────────────── + +@router.get("/actions/presets") +async def list_presets(request: Request): + pdir = _presets_dir(request) + presets = [] + for f in sorted(pdir.glob("*.json")): + try: + data = json.loads(f.read_text(encoding="utf-8")) + presets.append({ + "name": f.stem, + "label": data.get("label", f.stem), + "description": data.get("description", ""), + "action_count": len(data.get("actions", [])), + "saved_at": data.get("saved_at", ""), + }) + except Exception: + pass + return {"presets": presets} + + +@router.post("/actions/presets") +async def save_preset(request: Request, body: dict = Body(...)): + """Sauvegarde les actions actuelles comme preset.""" + label = body.get("name", "").strip() + description = body.get("description", "").strip() + if not label: + return JSONResponse({"error": "Nom requis"}, status_code=400) + + safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", label).strip("_") + if not safe_name: + return JSONResponse({"error": "Nom invalide"}, status_code=400) + + pb = request.app.state.photobooth_service + pb_config = await pb.read_pb_config() + actions = pb_config.get("actions", {}).get("image", []) + clean = [{k: v for k, v in copy.deepcopy(a).items() if k != "_index"} for a in actions] + + preset = { + "label": label, + "description": description, + "saved_at": datetime.now().isoformat(timespec="seconds"), + "actions": clean, + } + (_presets_dir(request) / f"{safe_name}.json").write_text( + json.dumps(preset, indent=2, ensure_ascii=False), encoding="utf-8" + ) + logger.info("Preset sauvegardé: %s (%d actions)", safe_name, len(clean)) + return {"ok": True, "name": safe_name, "action_count": len(clean)} + + +@router.post("/actions/presets/{name}/restore") +async def restore_preset(request: Request, name: str): + """Restaure un preset (remplace les actions actuelles).""" + path = _presets_dir(request) / f"{name}.json" + if not path.exists(): + return JSONResponse({"error": f"Preset introuvable: {name}"}, status_code=404) + try: + preset = json.loads(path.read_text(encoding="utf-8")) + except Exception as e: + return JSONResponse({"error": f"Lecture preset: {e}"}, status_code=500) + actions = preset.get("actions", []) + if not actions: + return JSONResponse({"error": "Preset vide"}, status_code=400) + pb = request.app.state.photobooth_service + pb_config = await pb.read_pb_config() + pb_config.setdefault("actions", {})["image"] = actions + await pb.write_pb_config(pb_config) + logger.info("Preset restauré: %s (%d actions)", name, len(actions)) + return {"ok": True, "name": name, "action_count": len(actions)} + + +@router.delete("/actions/presets/{name}") +async def delete_preset(request: Request, name: str): + path = _presets_dir(request) / f"{name}.json" + if not path.exists(): + return JSONResponse({"error": f"Preset introuvable: {name}"}, status_code=404) + path.unlink() + logger.info("Preset supprimé: %s", name) + return {"ok": True, "name": name} diff --git a/backend/api/admin_api.py b/backend/api/admin_api.py index 3b9dea2..a26c347 100644 --- a/backend/api/admin_api.py +++ b/backend/api/admin_api.py @@ -96,17 +96,8 @@ async def admin_actions(request: Request): redirect = _require_auth(request) if redirect: return redirect - - pb = request.app.state.photobooth_service cfg = request.app.state.config - pb_config = await pb.read_pb_config() - actions = pb_config.get("actions", {}).get("image", []) - - return _templates.TemplateResponse(request, "admin/actions.html", { - "config": cfg, - "pb_actions": actions, - "button_mapping": cfg.button_actions, - }) + return _templates.TemplateResponse(request, "admin/actions.html", {"config": cfg}) @router.get("/admin/settings", response_class=HTMLResponse) diff --git a/frontend/templates/admin/actions.html b/frontend/templates/admin/actions.html index 1e6970e..af85431 100644 --- a/frontend/templates/admin/actions.html +++ b/frontend/templates/admin/actions.html @@ -1,143 +1,170 @@ {% extends "base.html" %} {% block title %}Actions — JH Photomaton{% endblock %} +{% block head %} + +{% endblock %} {% block content %}
Associe chaque nombre de clics à une action dans photobooth-app. L'index correspond à la position dans la liste des actions (0 = première action).
-| Clics | -Label affiché | -Index action (photobooth) | -Action correspondante | -- |
|---|---|---|---|---|
| - {% if n == 1 %}1 clic{% else %}{{ n }} clics{% endif %} - | -- - | -- - | -- {% set idx = mapping.get('photobooth_index', 0) %} - {% if pb_actions and idx < pb_actions|length %} - {{ pb_actions[idx].name }} - {% else %}—{% endif %} - | -- - | -
Associe chaque nombre de clics à une action.
+Sauvegarder la configuration actuelle pour la restaurer plus tard (ex : mariage, baptême, anniversaire…).
+