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:
225
backend/main.py
225
backend/main.py
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user