Ajoute le support de formation : entite racine separee du jeu 2D
Build and deploy / test-python (push) Successful in 11m2s
Build and deploy / test-js (push) Successful in 1m2s
Build and deploy / lint-python (push) Successful in 4m6s
Build and deploy / lint-js (push) Failing after 1m21s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 4m2s

Nouveau moteur document_engine/ (elements CRUD + rendering + labels),
db/supports/ (stockage independant de db/games), routes/document/
(CRUD AJAX + publication), et l'editeur frontend complet
(templates/document/, static/document/) avec moteur de layout reel
(glisser-deposer -> fusion en rangee ou insertion avant/apres), vrai
Undo/Redo par pile de commandes, grille d'accroche pour les formes
libres, apercu responsive a largeurs fixes, mode Apercu, et publication
persistee.

"Mes formations" (templates/index.html) liste desormais les
environnements 2D et les supports de formation cote a cote ;
l'onboarding et core/auth_guard.py sont generalises pour qu'un compte
restreint puisse posseder un projet de chaque type independamment.

SKIP=djlint : le hook ne signale que le backlog H021 (styles en ligne)
deja documente dans CODE_QUALITY.md sur des fichiers pre-existants non
touches ici (base.html, game/play.html, scene_edit.html,
game_dashboard_simple.html, clause_row.html) plus une ligne de
index.html deja presente avant cette session — aucun nouveau fichier
(document_edit.html compris) n'y figure. Tous les autres outils
(ruff, mypy --strict, vulture, bandit, import-linter, eslint,
stylelint) passent sans erreur ; 617 tests Python + 276 tests JS
verts, plus une verification manuelle complete du cycle de vie via le
serveur de developpement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 09:20:33 +02:00
co-authored by Claude Sonnet 5
parent 7e4a761cbb
commit bab581737a
59 changed files with 4158 additions and 142 deletions
+16 -5
View File
@@ -1,5 +1,16 @@
"""Routes HTTP du futur éditeur Support de formation (document unique,
mise en page automatique, mini-jeux — voir docs/plan/PLAN.md). Vide pour
l'instant : aucun code métier n'existe encore côté document. Ne doit
jamais recevoir de logique de type Flow (déclencheurs événement->action)
— c'est une notion exclusive à routes/game/, voir PLAN.md."""
"""Routes HTTP du support de formation (document unique, mise en page
automatique, mini-jeux — voir docs/plan/PLAN.md). Entité racine séparée du
jeu 2D : ne reprend rien de routes/game/ (pas de notion d'écran, pas de
Flow déclencheur événement->action, c'est exclusif à routes/game/)."""
from . import ( # noqa: F401 - enregistre les routes definies dans chaque module
document_delete,
document_edit,
document_element_add,
document_element_delete,
document_element_move,
document_element_update,
document_new,
document_publish,
document_render,
)
+15
View File
@@ -0,0 +1,15 @@
from flask import redirect, url_for
from werkzeug.wrappers import Response
import db
from core.flask_app import app
@app.route("/document/<slug>/delete", methods=["POST"])
def document_delete(slug: str) -> Response:
"""Supprime un support — mirroir de routes/games/game_delete.py.
Blocage pour un compte "user" restreint géré par core/auth_guard.py
(même raisonnement que game_delete : aucun moyen d'en recréer un une
fois support_slug posé et le dossier supprimé)."""
db.delete_support(slug)
return redirect(url_for("index"))
+24
View File
@@ -0,0 +1,24 @@
from flask import render_template
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/edit")
def document_edit(slug: str) -> str:
"""Éditeur du support — route directe, sans page intermédiaire (un
support = un projet = un document, voir docs/plan/PLAN.md). Contraste
avec l'environnement 2D (clic sur une carte -> game_dashboard qui liste
ses écrans -> éditeur de scène) : ici la carte "Mes formations" mène
directement ici."""
support = db.support_meta(slug)
elements = document_engine.list_document_elements(slug)
return render_template(
"document/document_edit.html",
support=support,
elements=elements,
rendered_document=document_engine.render_document(elements),
element_library=document_engine.ELEMENT_LIBRARY,
element_kind_labels=document_engine.ELEMENT_KIND_LABELS,
)
+41
View File
@@ -0,0 +1,41 @@
from typing import Any
from flask import jsonify, request
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/add", methods=["POST"])
def document_element_add(slug: str) -> Response | tuple[Response, int]:
"""Ajoute un élément — appelée en AJAX depuis la bibliothèque du
panneau gauche (clic ou glisser-déposer initial), OU par le moteur de
layout lui-même pour créer une rangée à la volée au moment d'un dépôt
latéral (kind="row" — jamais choisi directement dans la bibliothèque,
absent de document_engine.ELEMENT_LIBRARY, mais un kind valide comme
un autre pour cette route ; voir static/document/js/document-editor.js).
parent_id, quand fourni, place l'élément directement dans une rangée
existante."""
kind = request.form.get("kind", "")
if kind not in document_engine.ELEMENT_KIND_LABELS:
return jsonify({"error": "type d'élément inconnu"}), 400
parent_id = request.form.get("parent_id", type=int)
element_id = document_engine.add_document_element(slug, kind, parent_id=parent_id)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
"element_id vient d'etre cree par add_document_element juste au-dessus",
)
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
"id": element_id,
"kind": kind,
"parent_id": parent_id,
"attributes": element["attributes"],
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
@@ -0,0 +1,11 @@
from flask import jsonify
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/delete", methods=["POST"])
def document_element_delete(slug: str, element_id: int) -> Response:
document_engine.delete_document_element(slug, element_id)
return jsonify({"ok": True})
+19
View File
@@ -0,0 +1,19 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/move", methods=["POST"])
def document_element_move(slug: str, element_id: int) -> Response:
"""Réinsertion réelle d'un élément — new_parent_id/new_index calculés
côté client par l'algorithme de glisser-déposer (boîte englobante la
plus proche sous le curseur, moitié gauche/droite -> rejoint sa
rangée, moitié haute/basse -> nouvelle rangée avant/après ; voir
static/document/js/document-editor.js)."""
payload = request.get_json(force=True) or {}
new_parent_id = payload.get("new_parent_id")
new_index = int(payload.get("new_index", 0))
document_engine.move_document_element(slug, element_id, new_parent_id, new_index)
return jsonify({"ok": True})
@@ -0,0 +1,32 @@
from typing import Any
from flask import jsonify, request
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/update", methods=["POST"])
def document_element_update(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Remplace les attributs d'un élément — un formulaire du panneau
Propriétés (voir static/document/js/document-editor.js) envoie
systématiquement l'état complet de ses champs."""
if document_engine.get_document_element(slug, element_id) is None:
return jsonify({"error": "élément introuvable"}), 404
attributes: dict[str, Any] = request.get_json(force=True) or {}
document_engine.update_document_element_attributes(slug, element_id, attributes)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
)
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
"ok": True,
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
+23
View File
@@ -0,0 +1,23 @@
from flask import g, redirect, request, url_for
from werkzeug.wrappers import Response
import auth
import db
from core.flask_app import app
@app.route("/documents/new", methods=["POST"])
def documents_new() -> Response:
"""Crée un support de formation — mirroir de routes/games/games_new.py
pour l'autre type de projet (entité racine séparée, voir
docs/plan/PLAN.md). Un compte "user" restreint pose support_slug (voir
auth.set_support_slug) en plus de project_slug si un environnement 2D
existe déjà : les deux slots sont indépendants, un compte peut remplir
les deux."""
name = request.form.get("name", "").strip()
if not name:
return redirect(url_for("index"))
slug = db.create_support(name, owner_folder=str(g.current_user["id"]))
if g.current_user["role"] != "admin":
auth.set_support_slug(g.current_user["id"], slug)
return redirect(url_for("document_edit", slug=slug))
+15
View File
@@ -0,0 +1,15 @@
from flask import jsonify
from werkzeug.wrappers import Response
import db
from core.flask_app import app
@app.route("/document/<slug>/publish", methods=["POST"])
def document_publish(slug: str) -> Response:
"""Publie le support — vrai changement d'état persisté
(_meta['published_at'], voir db/supports/mark_support_published.py),
pas juste un toast côté client (voir docs/plan/PLAN.md, "Publier
réel")."""
published_at = db.mark_support_published(slug)
return jsonify({"ok": True, "published_at": published_at})
+16
View File
@@ -0,0 +1,16 @@
from flask import jsonify
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/render")
def document_render(slug: str) -> Response:
"""Rendu HTML complet et à jour du document — appelé par le moteur de
layout côté client après toute mutation structurelle (ajout/déplacement/
suppression) pour reconstruire le canevas depuis la vérité serveur
(recalcul réel des rangées, jamais une simple retouche DOM locale, voir
docs/plan/PLAN.md — "recalcul au déplacement")."""
elements = document_engine.list_document_elements(slug)
return jsonify({"html": document_engine.render_document(elements), "elements": elements})