feat: gestion complète des actions (clone, delete, reorder, presets)
- actions_api.py: POST /create, POST /{idx}/clone, DELETE /{idx},
PUT /reorder, PUT /{idx} étendu (ui_trigger.*),
GET/POST/POST-restore/DELETE /presets
- admin_api.py: route /admin/actions simplifiée (plus de pré-chargement Jinja2)
- actions.html: page full JS dynamique
· Drag & drop pour réordonner (barre de sauvegarde explicite)
· Clone, suppression, création par bouton
· Edit form complet: général, cadre, fond, traitement, UI bouton
· Sélecteurs cadres/fonds avec dropdown + saisie libre
· Mapping bouton dynamique
· Presets: save/restore/delete avec nom + description
This commit is contained in:
+233
-57
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user