Videos didactiques multi-etapes : wizard operateur + admin dynamique

Backend : 8 endpoints API (upload/delete/reorder/toggle/label/stream/creer etape),
stockage data/videos/{situation}/etape_XX.mp4, config.json toggle+labels,
6 situations par defaut, migration auto anciens fichiers flat.
Admin : panneau dynamique avec toggle, etapes reordonnables, labels editables.
Wizard booth : overlay plein ecran, video loop par etape, bouton validation sequentielle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 20:41:05 +02:00
parent 5e482a89e7
commit 3aa3068127
4 changed files with 552 additions and 91 deletions

View File

@@ -1427,47 +1427,212 @@ async def _lancer_photostation():
FLAG_PHOTOSTATION.unlink(missing_ok=True)
# --- API Videos de situation ---
# --- API Videos didactiques (multi-etapes) ---
DOSSIER_VIDEOS = RACINE / "data" / "videos"
DOSSIER_VIDEOS.mkdir(parents=True, exist_ok=True)
FICHIER_VIDEOS_CONFIG = DOSSIER_VIDEOS / "config.json"
SITUATIONS_DEFAUT = [
{"id": "montage", "label": "Montage / Mise en service"},
{"id": "demontage", "label": "Demontage"},
{"id": "bourrage", "label": "Bourrage imprimante"},
{"id": "changement_rouleau", "label": "Changement rouleau / ruban"},
{"id": "wifi", "label": "Probleme WiFi"},
{"id": "depannage_imprimante", "label": "Depannage imprimante"},
]
def charger_videos_config():
if FICHIER_VIDEOS_CONFIG.exists():
with open(FICHIER_VIDEOS_CONFIG) as f:
return json.load(f)
return {}
def sauvegarder_videos_config(cfg):
with open(FICHIER_VIDEOS_CONFIG, "w") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def _lister_etapes(situation: str):
dossier = DOSSIER_VIDEOS / situation
if not dossier.is_dir():
return []
fichiers = sorted(
[f for f in dossier.iterdir() if f.is_file() and f.suffix.lower() in ('.mp4', '.webm', '.mov')],
key=lambda f: f.name
)
return [{"index": i, "fichier": f.name} for i, f in enumerate(fichiers)]
def _migrer_anciens_videos():
for f in DOSSIER_VIDEOS.iterdir():
if f.is_file() and f.suffix.lower() in ('.mp4', '.webm', '.mov'):
situation = f.stem
dossier = DOSSIER_VIDEOS / situation
dossier.mkdir(exist_ok=True)
dest = dossier / f"etape_00{f.suffix}"
f.rename(dest)
_migrer_anciens_videos()
@app.get("/api/videos")
async def api_videos():
videos = {}
for f in DOSSIER_VIDEOS.iterdir():
if f.is_file() and f.suffix.lower() in ('.mp4', '.webm', '.mov'):
videos[f.stem] = f.name
return {"videos": videos}
cfg = charger_videos_config()
result = {}
for sit in SITUATIONS_DEFAUT:
sid = sit["id"]
etapes = _lister_etapes(sid)
sit_cfg = cfg.get(sid, {})
result[sid] = {
"label": sit.get("label", sid),
"actif": sit_cfg.get("actif", False),
"etapes": etapes,
"labels_etapes": sit_cfg.get("labels_etapes", {}),
}
for sid in sorted(cfg.keys()):
if sid not in result:
etapes = _lister_etapes(sid)
result[sid] = {
"label": cfg[sid].get("label", sid),
"actif": cfg[sid].get("actif", False),
"etapes": etapes,
"labels_etapes": cfg[sid].get("labels_etapes", {}),
}
return {"situations": result}
@app.post("/api/videos/upload")
async def api_upload_video(video: UploadFile = File(...), situation: str = ""):
if not situation:
return JSONResponse({"erreur": "situation requise"}, 400)
ext = Path(video.filename).suffix.lower() or '.mp4'
dest = DOSSIER_VIDEOS / f"{situation}{ext}"
for old in DOSSIER_VIDEOS.glob(f"{situation}.*"):
old.unlink()
with open(dest, "wb") as f:
while chunk := await video.read(1024 * 1024):
f.write(chunk)
return {"ok": True, "fichier": dest.name}
@app.delete("/api/videos/{situation}")
async def api_delete_video(situation: str):
for f in DOSSIER_VIDEOS.glob(f"{situation}.*"):
f.unlink()
@app.post("/api/videos/{situation}/toggle")
async def api_toggle_situation(situation: str, req: Request):
body = await req.json()
cfg = charger_videos_config()
if situation not in cfg:
cfg[situation] = {}
cfg[situation]["actif"] = bool(body.get("actif", False))
sauvegarder_videos_config(cfg)
return {"ok": True}
@app.get("/api/videos/{situation}/stream")
async def api_stream_video(situation: str):
for f in DOSSIER_VIDEOS.glob(f"{situation}.*"):
media = "video/mp4" if f.suffix == ".mp4" else "video/webm"
return FileResponse(f, media_type=media)
return JSONResponse({"erreur": "video introuvable"}, 404)
@app.post("/api/videos/{situation}/upload")
async def api_upload_etape(situation: str, video: UploadFile = File(...)):
dossier = DOSSIER_VIDEOS / situation
dossier.mkdir(parents=True, exist_ok=True)
existantes = sorted(dossier.glob("etape_*"))
prochain = len(existantes)
ext = Path(video.filename).suffix.lower() or '.mp4'
dest = dossier / f"etape_{prochain:02d}{ext}"
with open(dest, "wb") as f:
while chunk := await video.read(1024 * 1024):
f.write(chunk)
return {"ok": True, "fichier": dest.name, "index": prochain}
@app.delete("/api/videos/{situation}/{index}")
async def api_delete_etape(situation: str, index: int):
dossier = DOSSIER_VIDEOS / situation
if not dossier.is_dir():
return JSONResponse({"erreur": "situation introuvable"}, 404)
fichiers = sorted(
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
key=lambda f: f.name
)
if index < 0 or index >= len(fichiers):
return JSONResponse({"erreur": "index invalide"}, 400)
fichiers[index].unlink()
for i, f in enumerate(sorted(
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
key=lambda f: f.name
)):
nouveau = dossier / f"etape_{i:02d}{f.suffix}"
if f != nouveau:
f.rename(nouveau)
cfg = charger_videos_config()
sit_cfg = cfg.get(situation, {})
old_labels = sit_cfg.get("labels_etapes", {})
new_labels = {}
for k, v in old_labels.items():
ki = int(k)
if ki < index:
new_labels[str(ki)] = v
elif ki > index:
new_labels[str(ki - 1)] = v
if old_labels != new_labels:
sit_cfg["labels_etapes"] = new_labels
cfg[situation] = sit_cfg
sauvegarder_videos_config(cfg)
return {"ok": True}
@app.post("/api/videos/{situation}/reorder")
async def api_reorder_etapes(situation: str, req: Request):
body = await req.json()
old_idx = body.get("de")
new_idx = body.get("vers")
dossier = DOSSIER_VIDEOS / situation
if not dossier.is_dir():
return JSONResponse({"erreur": "situation introuvable"}, 404)
fichiers = sorted(
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
key=lambda f: f.name
)
if old_idx is None or new_idx is None or old_idx < 0 or old_idx >= len(fichiers):
return JSONResponse({"erreur": "index invalide"}, 400)
moved = fichiers.pop(old_idx)
fichiers.insert(new_idx, moved)
tmp_names = []
for i, f in enumerate(fichiers):
tmp = dossier / f"_tmp_{i:02d}{f.suffix}"
f.rename(tmp)
tmp_names.append(tmp)
for i, tmp in enumerate(tmp_names):
ext = tmp.suffix
final = dossier / f"etape_{i:02d}{ext}"
tmp.rename(final)
return {"ok": True}
@app.post("/api/videos/{situation}/label")
async def api_label_etape(situation: str, req: Request):
body = await req.json()
index = body.get("index")
label = body.get("label", "")
cfg = charger_videos_config()
if situation not in cfg:
cfg[situation] = {}
labels = cfg[situation].setdefault("labels_etapes", {})
labels[str(index)] = label
sauvegarder_videos_config(cfg)
return {"ok": True}
@app.get("/api/videos/{situation}/{index}/stream")
async def api_stream_etape(situation: str, index: int):
dossier = DOSSIER_VIDEOS / situation
if not dossier.is_dir():
return JSONResponse({"erreur": "situation introuvable"}, 404)
fichiers = sorted(
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
key=lambda f: f.name
)
if index < 0 or index >= len(fichiers):
return JSONResponse({"erreur": "index invalide"}, 404)
f = fichiers[index]
media = "video/mp4" if f.suffix == ".mp4" else "video/webm"
return FileResponse(f, media_type=media)
@app.post("/api/videos/situation/creer")
async def api_creer_situation(req: Request):
body = await req.json()
sid = body.get("id", "").strip().lower().replace(" ", "_")
label = body.get("label", sid)
if not sid:
return JSONResponse({"erreur": "id requis"}, 400)
cfg = charger_videos_config()
if sid not in cfg:
cfg[sid] = {"label": label, "actif": False, "labels_etapes": {}}
sauvegarder_videos_config(cfg)
dossier = DOSSIER_VIDEOS / sid
dossier.mkdir(parents=True, exist_ok=True)
return {"ok": True}
# --- API Surprise ---