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
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:
co-authored by
Claude Sonnet 5
parent
7e4a761cbb
commit
bab581737a
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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})
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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})
|
||||
@@ -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})
|
||||
+10
-2
@@ -6,7 +6,15 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/")
|
||||
def index() -> str:
|
||||
# Toujours SES PROPRES projets (voir db.list_games, corrigé pour ne
|
||||
# "Mes formations" (voir docs/plan/PLAN.md) : les deux types de projet
|
||||
# sont listés côte à côte, jamais mélangés dans les mêmes données
|
||||
# (db.list_games/db.list_supports, deux systèmes de fichiers
|
||||
# entièrement séparés). Toujours SES PROPRES projets (corrigé pour ne
|
||||
# plus jamais scanner les autres comptes) — même pour un admin, qui
|
||||
# n'a donc plus aucune visibilité sur les projets des autres comptes.
|
||||
return render_template("index.html", games=db.list_games(str(g.current_user["id"])))
|
||||
owner_folder = str(g.current_user["id"])
|
||||
return render_template(
|
||||
"index.html",
|
||||
games=db.list_games(owner_folder),
|
||||
supports=db.list_supports(owner_folder),
|
||||
)
|
||||
|
||||
@@ -8,61 +8,91 @@ import db
|
||||
import game_engine
|
||||
from core.flask_app import app
|
||||
|
||||
# Choix de PREMIER niveau, avant le catalogue des types de jeu (voir
|
||||
# db.ONBOARDING_TYPES) : "support" n'y figure jamais (schéma/notions
|
||||
# entièrement différents, voir document_engine/), posé ici comme une
|
||||
# carte de plus dans la même grille visuelle (templates/onboarding/
|
||||
# onboarding_new.html réutilise sa mécanique de sélection existante sans
|
||||
# aucune modification JS, voir static/js/onboarding/onboarding_new.js).
|
||||
_SUPPORT_ONBOARDING_CARD: dict[str, Any] = {
|
||||
"label": "Support de formation",
|
||||
"tagline": "Un contenu structuré, prêt à publier.",
|
||||
"icon": "📄",
|
||||
"description": (
|
||||
'<span class="onboardingPitchTitle">Un support de formation clair,'
|
||||
" sans une ligne de code</span>"
|
||||
'<ul class="onboardingPitchList">'
|
||||
'<li><span class="onboardingPitchCheck">✓</span>'
|
||||
"<span><strong>Mise en page automatique</strong> — dépose tes blocs,"
|
||||
" l'alignement se fait tout seul</span></li>"
|
||||
'<li><span class="onboardingPitchCheck">✓</span>'
|
||||
"<span><strong>Formes & mini-jeux</strong> — habille et anime ton"
|
||||
" contenu à volonté</span></li>"
|
||||
'<li><span class="onboardingPitchCheck">✓</span>'
|
||||
"<span><strong>Aperçu & publication</strong> — vois exactement ce que"
|
||||
" verra l'apprenant</span></li>"
|
||||
"</ul>"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.route("/onboarding", methods=["GET", "POST"])
|
||||
def onboarding_new() -> str | Response:
|
||||
"""Point d'entrée UNIQUE pour créer un jeu — comportement SYSTÉMATIQUE
|
||||
à chaque création, pas une formalité réservée à la toute première
|
||||
inscription : chaque projet (celui d'un compte "user" comme n'importe
|
||||
lequel des projets d'un admin) choisit son propre type ici. Prépare
|
||||
aussi le futur multi-projet payant (plusieurs jeux de types
|
||||
différents pour un même compte "user").
|
||||
|
||||
Un compte "user" fraîchement inscrit n'a plus de projet créé d'office
|
||||
(voir routes/auth/register_2fa.py) : core/auth_guard.py le redirige
|
||||
ici tant qu'il n'a pas encore de project_slug — et n'en aura jamais
|
||||
qu'UN SEUL (voir core/auth_guard.py). Un admin, lui, n'a PAS de
|
||||
project_slug dédié (illimité, voir create_user.py) : repasse ici à
|
||||
volonté pour créer un projet de PLUS, jamais redirigé ailleurs.
|
||||
|
||||
Choisit un type (voir db.ONBOARDING_TYPES) puis nomme son jeu — crée
|
||||
le projet ET son premier écran (du "kind" correspondant), puis envoie
|
||||
vers son tableau de bord (routes/games/game_dashboard.py y rend une
|
||||
version SIMPLIFIÉE pour un type restreint — quiz/embranchement/rpg —
|
||||
ou la version complète pour "custom", même route pour tous)."""
|
||||
"""Point d'entrée UNIQUE pour créer un projet — jeu (environnement 2D)
|
||||
OU support de formation (voir docs/plan/PLAN.md), deux entités racines
|
||||
séparées. Un compte peut avoir un projet DE CHAQUE type (project_slug
|
||||
ET support_slug, voir core/auth_guard.py) : chaque carte du catalogue
|
||||
n'est proposée que si son slot correspondant n'est pas déjà rempli
|
||||
(compte "user" restreint — un admin, lui, n'a aucun slot dédié, illimité
|
||||
des deux côtés)."""
|
||||
user = g.current_user
|
||||
if user["role"] != "admin" and user.get("project_slug"):
|
||||
# Retour en arrière après coup (l'onboarding a déjà été fait) —
|
||||
# ne recrée rien, renvoie directement vers son tableau de bord. Ne
|
||||
# s'applique PAS à un admin (illimité, jamais "déjà fait" une fois
|
||||
# pour toutes).
|
||||
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
||||
restricted = user["role"] != "admin"
|
||||
has_game = restricted and bool(user.get("project_slug"))
|
||||
has_support = restricted and bool(user.get("support_slug"))
|
||||
if has_game and has_support:
|
||||
# Les deux slots sont déjà remplis — plus rien à créer ici.
|
||||
return redirect(url_for("index"))
|
||||
|
||||
onboarding_types: dict[str, dict[str, Any]] = {}
|
||||
if not has_support:
|
||||
onboarding_types["support"] = _SUPPORT_ONBOARDING_CARD
|
||||
if not has_game:
|
||||
onboarding_types.update(db.ONBOARDING_TYPES)
|
||||
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
onboarding_type = request.form.get("onboarding_type", "")
|
||||
name = request.form.get("name", "").strip()
|
||||
if onboarding_type not in db.ONBOARDING_TYPES:
|
||||
error = "Choisis un type de jeu."
|
||||
if onboarding_type not in onboarding_types:
|
||||
error = "Choisis un type de projet."
|
||||
elif not name:
|
||||
error = "Donne un nom à ton jeu."
|
||||
error = "Donne un nom à ton projet."
|
||||
elif onboarding_type == "support":
|
||||
return redirect(_create_support_for_user(user, name))
|
||||
else:
|
||||
return redirect(_create_project_for_user(user, onboarding_type, name))
|
||||
|
||||
return render_template(
|
||||
"onboarding/onboarding_new.html",
|
||||
onboarding_types=db.ONBOARDING_TYPES,
|
||||
onboarding_types=onboarding_types,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _create_support_for_user(user: dict[str, Any], name: str) -> str:
|
||||
slug = db.create_support(name, owner_folder=str(user["id"]))
|
||||
if user["role"] != "admin":
|
||||
auth.set_support_slug(user["id"], slug)
|
||||
return url_for("document_edit", slug=slug)
|
||||
|
||||
|
||||
def _create_project_for_user(user: dict[str, Any], onboarding_type: str, name: str) -> str:
|
||||
# owner_folder = l'id du compte, jamais son email (voir
|
||||
# routes/games/games_new.py, même convention).
|
||||
slug = db.create_game(name, owner_folder=str(user["id"]))
|
||||
db.set_onboarding_type(slug, onboarding_type)
|
||||
if user["role"] != "admin":
|
||||
# project_slug = LE seul projet d'un compte "user" (voir
|
||||
# project_slug = LE seul environnement 2D d'un compte "user" (voir
|
||||
# core/auth_guard.py) — un admin n'utilise pas cette colonne, ses
|
||||
# projets se retrouvent via db.list_games() comme avant.
|
||||
auth.set_project_slug(user["id"], slug)
|
||||
|
||||
Reference in New Issue
Block a user