Implemente un systeme de pages pour le support de formation
Build and deploy / test-python (push) Successful in 7m48s
Build and deploy / test-js (push) Successful in 52s
Build and deploy / lint-python (push) Successful in 5m44s
Build and deploy / lint-js (push) Failing after 1m52s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m5s

Retour utilisateur : "il faut implementer un systeme de page". Un
support est desormais compose de PLUSIEURS pages (_document_pages),
chacune un document independant affiche seul sur le canevas -- chaque
element appartient a exactement une page via page_id (document_engine/
elements/*, routes/document/document_element_add.py/document_render.py
revalident desormais un page_id explicite). Migration automatique et
silencieuse pour les supports crees avant cette fonctionnalite
(db/supports/ensure_document_pages_schema.py, meme convention que les
ensure_X_schema.py existants) : leurs elements deviennent tous les
enfants d'une "Page 1" creee a la volee, aucune perte de contenu.

Nouveau paquet document_engine/pages/ (add/list/get/rename/move/delete)
et 4 routes dediees (routes/document/document_page_*.py) -- supprimer
la DERNIERE page restante est refuse (garde-fou pose a la route, meme
decoupage que routes/game/screens/screen_delete.py cote jeu, jamais
dans la fonction bas niveau).

Cote editeur : une bande d'ONGLETS au-dessus du canevas (jamais un
panneau lateral, choix explicite de l'utilisateur) -- clic pour changer
de page, double-clic pour renommer (contenteditable), glisser pour
reordonner, "+" pour ajouter, "x" pour supprimer. Changer de page vide
la pile Annuler/Retablir (une commande empilee sur une autre page n'a
plus de sens). Mode Apercu : navigation Page precedente/suivante avec
indicateur "Page X / N" (choix explicite : page par page, pas de
defilement continu), jamais affichee s'il n'y a qu'une seule page.

Verifie : suite pytest complete (702 tests, dont 14 nouveaux pour les
routes de pages), simulation DOM reelle (jsdom, 25 assertions couvrant
tout le cycle de vie cote client -- creation/bascule/renommage/
reordonnancement/suppression de page, portee correcte des elements par
page, pile Annuler/Retablir videe au changement de page, pilule de
navigation en Apercu), et un test de fumee HTTP reel contre le serveur
de dev en marche (creation/ajout d'element/rendu/renommage/suppression
d'une page, refus de supprimer la derniere page, page inconnue -> 404).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-21 13:24:23 +02:00
co-authored by Claude Sonnet 5
parent ecea430ee2
commit ecd483f352
33 changed files with 1048 additions and 110 deletions
+21 -5
View File
@@ -1,16 +1,20 @@
"""
document_engine — support de formation : entité racine séparée du jeu 2D
(voir docs/plan/PLAN.md). Un support = un seul document, structuré en deux
couches sur le même canevas :
(voir docs/plan/PLAN.md). Un support est composé de plusieurs PAGES (voir
document_engine/pages/, retour utilisateur du 21/09/2026 : "il faut
implémenter un système de page") ; chaque page structure son propre
contenu en deux couches sur le même canevas :
- le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé
en rangées ("row") par le moteur d'inférence de layout (voir
document_engine/rendering/render_document_element.py) ;
- la couche de formes libres (rectangle/cercle/triangle/trait), position-
nées en absolu (x/y/width/height/rotation/z_index).
Tout est stocké dans support.db (voir db/supports/) : une seule table,
_document_elements, portant les deux couches via parent_id (NULL = top-
niveau ou forme libre, sinon = enfant d'une rangée).
Tout est stocké dans support.db (voir db/supports/) : _document_pages (une
ligne par page) et _document_elements (chaque élément appartient à
exactement une page via page_id, les deux couches ci-dessus portées via
parent_id — NULL = top-niveau ou forme libre, sinon = enfant d'une
rangée).
Aucun import croisé avec game_engine ou tout module lié au jeu 2D — voir
le contrat import-linter dans pyproject.toml (game_engine | document_engine
@@ -69,6 +73,12 @@ from .labels.scenario_config import (
MAX_SCENARIO_CHOICES,
sanitize_scenario_config,
)
from .pages.add_document_page import add_document_page
from .pages.delete_document_page import delete_document_page
from .pages.get_document_page import get_document_page
from .pages.list_document_pages import list_document_pages
from .pages.move_document_page import move_document_page
from .pages.update_document_page import update_document_page
from .rendering.render_document_element import render_document, render_document_element
__all__ = [
@@ -95,11 +105,16 @@ __all__ = [
"MINIGAME_KINDS",
"SHAPE_KINDS",
"add_document_element",
"add_document_page",
"delete_document_element",
"delete_document_page",
"element_default_attributes",
"get_document_element",
"get_document_page",
"list_document_elements",
"list_document_pages",
"move_document_element",
"move_document_page",
"quiz_total_points",
"render_document",
"render_document_element",
@@ -110,4 +125,5 @@ __all__ = [
"sanitize_quiz_config",
"sanitize_scenario_config",
"update_document_element_attributes",
"update_document_page",
]
@@ -1,23 +1,29 @@
import json
from db.supports import connect_support
from db.supports import connect_support, ensure_document_pages_schema
from ..labels.element_kind_labels import element_default_attributes
def add_document_element(slug: str, kind: str, parent_id: int | None = None) -> int:
"""Ajoute un élément en fin de son groupe de frères (même parent_id —
NULL pour un élément top-niveau, l'id d'une rangée pour un enfant de
cette rangée, voir docs/plan/PLAN.md). Attributs de départ posés via
element_default_attributes(kind)."""
def add_document_element(slug: str, kind: str, page_id: int, parent_id: int | None = None) -> int:
"""Ajoute un élément à une page précise, en fin de son groupe de
frères (même parent_id — NULL pour un élément top-niveau, l'id d'une
rangée pour un enfant de cette rangée, voir docs/plan/PLAN.md).
Attributs de départ posés via element_default_attributes(kind). Le
groupe de frères (parent_id) est TOUJOURS cherché à l'intérieur de
cette même page — deux pages peuvent chacune avoir une rangée dont
les enfants portent des id `parent_id` différents, jamais de
confusion possible entre pages."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
max_row = conn.execute(
"SELECT MAX(order_index) AS m FROM _document_elements WHERE parent_id IS ?", (parent_id,)
"SELECT MAX(order_index) AS m FROM _document_elements WHERE page_id = ? AND parent_id IS ?",
(page_id, parent_id),
).fetchone()
order_index = (max_row["m"] or 0) + 1 if max_row and max_row["m"] is not None else 0
conn.execute(
"INSERT INTO _document_elements (parent_id, kind, order_index, attributes) VALUES (?, ?, ?, ?)",
(parent_id, kind, order_index, json.dumps(element_default_attributes(kind))),
"INSERT INTO _document_elements (page_id, parent_id, kind, order_index, attributes) VALUES (?, ?, ?, ?, ?)",
(page_id, parent_id, kind, order_index, json.dumps(element_default_attributes(kind))),
)
element_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
@@ -1,10 +1,11 @@
from db.supports import connect_support
from db.supports import connect_support, ensure_document_pages_schema
def delete_document_element(slug: str, element_id: int) -> None:
"""Supprime un élément — CASCADE (contrainte FK, voir
db/supports/create_support.py) retire aussi ses enfants si c'était une
rangée."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
conn.execute("DELETE FROM _document_elements WHERE id = ?", (element_id,))
conn.commit()
+19 -10
View File
@@ -1,23 +1,29 @@
# document_engine/elements/
CRUD des éléments d'un support de formation (`_document_elements`, voir
`db/supports/create_support.py`). Un élément est soit un élément de
contenu/rangée du flux (`parent_id` = groupe de frères), soit une forme
libre superposée en position absolue (toujours `parent_id = NULL`).
CRUD des éléments d'UNE PAGE d'un support de formation
(`_document_elements`, voir `db/supports/create_support.py`) — un
support est composé de plusieurs pages (voir `document_engine/pages/`),
chaque élément appartient à exactement une page via `page_id`, jamais
partagé entre pages. Un élément est soit un élément de contenu/rangée du
flux (`parent_id` = groupe de frères, toujours dans la MÊME page), soit
une forme libre superposée en position absolue (toujours `parent_id =
NULL`).
## `add_document_element(slug: str, kind: str, parent_id: int | None = None) -> int`
Insère un nouvel élément en fin de son groupe de frères (même `parent_id`).
## `add_document_element(slug: str, kind: str, page_id: int, parent_id: int | None = None) -> int`
Insère un nouvel élément dans `page_id`, en fin de son groupe de frères
(même `parent_id`, recherché uniquement à l'intérieur de cette page).
Pose les attributs de départ via `element_default_attributes(kind)`
(voir `document_engine/labels/element_kind_labels.py`).
- **Retour** : l'`id` du nouvel élément.
- **Exceptions** : aucune levée explicitement ; une connexion invalide
(support inexistant) lève l'erreur SQLite sous-jacente.
## `list_document_elements(slug: str) -> list[dict[str, Any]]`
Renvoie tous les éléments du support, à plat, triés par `(parent_id,
## `list_document_elements(slug: str, page_id: int) -> list[dict[str, Any]]`
Renvoie tous les éléments de `page_id`, à plat, triés par `(parent_id,
order_index)` — les éléments top-niveau (`parent_id` NULL) groupés en
premier (tri SQLite : NULL avant toute valeur), puis chaque rangée
groupant ses propres enfants. `attributes` est décodé en dict.
groupant ses propres enfants. `attributes` est décodé en dict. Le
canevas n'affiche jamais qu'une seule page à la fois, d'où ce filtrage.
- **Retour** : liste de dicts (une ligne de table chacun, `attributes`
déjà en JSON décodé).
- **Exceptions** : aucune.
@@ -42,7 +48,10 @@ de layout : dépose l'élément dans un groupe de frères (nouvelle rangée,
un groupe existant, ou le top-niveau) à une position précise, puis
renumérote intégralement le ou les groupes concernés (ancien et nouveau
si le parent change, un seul sinon) pour rester correct même en cas de
réordonnancement dans le même groupe.
réordonnancement dans le même groupe. `new_parent_id` doit toujours
désigner un élément de la MÊME page que `element_id` — jamais vérifié
ici (le client ne propose que des cibles de la page actuellement
affichée).
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne fait rien (retour silencieux après vérification de son
@@ -1,10 +1,11 @@
import json
from typing import Any
from db.supports import connect_support
from db.supports import connect_support, ensure_document_pages_schema
def get_document_element(slug: str, element_id: int) -> dict[str, Any] | None:
ensure_document_pages_schema(slug)
conn = connect_support(slug)
row = conn.execute("SELECT * FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
conn.close()
@@ -1,21 +1,26 @@
import json
from typing import Any
from db.supports import connect_support
from db.supports import connect_support, ensure_document_pages_schema
def list_document_elements(slug: str) -> list[dict[str, Any]]:
"""Tous les éléments d'un support, à PLAT — chaque élément porte son
propre parent_id (NULL = top-niveau, sinon l'id d'une rangée) ; la
reconstitution de l'arbre (rangées + leurs enfants dans l'ordre) se
fait côté rendu (voir document_engine.render_document_element) et
côté JS pour l'affichage du canevas."""
def list_document_elements(slug: str, page_id: int) -> list[dict[str, Any]]:
"""Tous les éléments d'UNE PAGE du support, à PLAT — chaque élément
porte son propre parent_id (NULL = top-niveau, sinon l'id d'une
rangée) ; la reconstitution de l'arbre (rangées + leurs enfants dans
l'ordre) se fait côté rendu (voir document_engine.render_document_element)
et côté JS pour l'affichage du canevas. Un support est composé de
plusieurs pages (voir document_engine/pages/) : le canevas n'affiche
jamais qu'une seule page à la fois, d'où ce filtrage par page_id."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
# SQLite trie NULL avant toute valeur : les éléments top-niveau
# (parent_id NULL) arrivent groupés en premier, puis chaque rangée
# groupe ses propres enfants — chacun trié par order_index à
# l'intérieur de son groupe.
rows = conn.execute("SELECT * FROM _document_elements ORDER BY parent_id, order_index").fetchall()
rows = conn.execute(
"SELECT * FROM _document_elements WHERE page_id = ? ORDER BY parent_id, order_index", (page_id,)
).fetchall()
conn.close()
elements = []
for row in rows:
@@ -1,4 +1,4 @@
from db.supports import connect_support
from db.supports import connect_support, ensure_document_pages_schema
def move_document_element(slug: str, element_id: int, new_parent_id: int | None, new_index: int) -> None:
@@ -8,7 +8,11 @@ def move_document_element(slug: str, element_id: int, new_parent_id: int | None,
une position précise, pas juste "monter/descendre" d'un cran. Renumérote
intégralement les deux groupes concernés (ancien et nouveau, ou un seul
si inchangé) plutôt que de décaler un par un, pour rester correct même
en cas de réordonnancement DANS le même groupe."""
en cas de réordonnancement DANS le même groupe. new_parent_id doit
toujours désigner un élément de la MÊME page que element_id (jamais
vérifié ici — le client ne propose que des cibles de la page
actuellement affichée, voir static/document/js/document-editor.js)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
row = conn.execute("SELECT parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
@@ -1,7 +1,7 @@
import json
from typing import Any
from db.supports import connect_support
from db.supports import connect_support, ensure_document_pages_schema
def update_document_element_attributes(slug: str, element_id: int, attributes: dict[str, Any]) -> None:
@@ -9,6 +9,7 @@ def update_document_element_attributes(slug: str, element_id: int, attributes: d
Propriétés envoie systématiquement l'état entier de ses champs, jamais
un patch partiel (voir docs/plan/PLAN.md §1.2.D : "chaque formulaire
est autonome")."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
conn.execute(
"UPDATE _document_elements SET attributes = ? WHERE id = ?",
@@ -0,0 +1,23 @@
from db.supports import connect_support, ensure_document_pages_schema
def add_document_page(slug: str, title: str | None = None) -> int:
"""Ajoute une page en fin de la bande d'onglets — titre par défaut
"Page N" (N = position 1-indexée dans la liste actuelle + 1) si
`title` n'est pas fourni, jamais un titre vide (voir
update_document_page.py, qui lui rejette silencieusement un titre
vide au renommage — ici la valeur par défaut ne peut structurellement
pas être vide, donc rien à valider)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
max_row = conn.execute("SELECT MAX(order_index) AS m, COUNT(*) AS n FROM _document_pages").fetchone()
order_index = (max_row["m"] or 0) + 1 if max_row["n"] else 0
page_title = title.strip() if title and title.strip() else f"Page {max_row['n'] + 1}"
conn.execute(
"INSERT INTO _document_pages (title, order_index) VALUES (?, ?)",
(page_title, order_index),
)
page_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return page_id
@@ -0,0 +1,18 @@
from db.supports import connect_support, ensure_document_pages_schema
def delete_document_page(slug: str, page_id: int) -> None:
"""Supprime une page — CASCADE (contrainte FK sur les supports créés
après l'ajout des pages ; ensure_document_pages_schema n'en pose pas
pour les anciens, voir son commentaire) retire aussi ses éléments.
Ne refuse JAMAIS ici de supprimer la dernière page restante — cette
règle est un garde-fou métier posé par l'appelant (voir
routes/document/document_page_delete.py), pas une contrainte
structurelle de cette fonction bas niveau (même découpage que
routes/game/screens/screen_delete.py côté jeu)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
conn.execute("DELETE FROM _document_elements WHERE page_id = ?", (page_id,))
conn.execute("DELETE FROM _document_pages WHERE id = ?", (page_id,))
conn.commit()
conn.close()
@@ -0,0 +1,11 @@
from typing import Any
from db.supports import connect_support, ensure_document_pages_schema
def get_document_page(slug: str, page_id: int) -> dict[str, Any] | None:
ensure_document_pages_schema(slug)
conn = connect_support(slug)
row = conn.execute("SELECT * FROM _document_pages WHERE id = ?", (page_id,)).fetchone()
conn.close()
return dict(row) if row else None
@@ -0,0 +1,16 @@
from typing import Any
from db.supports import connect_support, ensure_document_pages_schema
def list_document_pages(slug: str) -> list[dict[str, Any]]:
"""Toutes les pages d'un support, triées par order_index — la bande
d'onglets du panneau Propriétés (voir static/document/js/
document-editor.js) et le sélecteur de page du Mode Aperçu en dérivent
directement. Un support a toujours au moins une page (voir
db/supports/create_support.py / ensure_document_pages_schema)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
rows = conn.execute("SELECT * FROM _document_pages ORDER BY order_index").fetchall()
conn.close()
return [dict(row) for row in rows]
@@ -0,0 +1,20 @@
from db.supports import connect_support, ensure_document_pages_schema
def move_document_page(slug: str, page_id: int, new_index: int) -> None:
"""Réordonne une page dans la bande d'onglets (glisser-déposer) —
renumérote intégralement order_index sur TOUTES les pages, même
principe que move_document_element (jamais un décalage un par un)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
page_ids = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_pages WHERE id != ? ORDER BY order_index", (page_id,)
).fetchall()
]
page_ids.insert(max(0, min(new_index, len(page_ids))), page_id)
for index, pid in enumerate(page_ids):
conn.execute("UPDATE _document_pages SET order_index = ? WHERE id = ?", (index, pid))
conn.commit()
conn.close()
+56
View File
@@ -0,0 +1,56 @@
# document_engine/pages/
CRUD des pages d'un support de formation (`_document_pages`, voir
`db/supports/create_support.py`) — retour utilisateur du 21/09/2026:
"il faut implémenter un système de page". Un support est désormais
composé de plusieurs pages, chacune portant son propre flux d'éléments
(voir `document_engine/elements/`, filtré par `page_id`). Un support a
TOUJOURS au moins une page (`create_support` en crée une par défaut,
`ensure_document_pages_schema` en garantit une pour les supports plus
anciens) — la garde "jamais supprimer la dernière page" est un
garde-fou métier posé par l'appelant (voir
`routes/document/document_page_delete.py`), pas une contrainte de ce
paquet.
## `add_document_page(slug: str, title: str | None = None) -> int`
Ajoute une page en fin de la bande d'onglets. `title` par défaut :
"Page N" (N = position 1-indexée + 1) si non fourni.
- **Retour** : l'`id` de la nouvelle page.
- **Exceptions** : aucune levée explicitement.
## `list_document_pages(slug: str) -> list[dict[str, Any]]`
Toutes les pages du support, triées par `order_index`.
- **Retour** : liste de dicts (une ligne de table chacun).
- **Exceptions** : aucune.
## `get_document_page(slug: str, page_id: int) -> dict[str, Any] | None`
Récupère une seule page par id.
- **Retour** : le dict de la page, ou `None` si l'id n'existe pas.
- **Exceptions** : aucune.
## `update_document_page(slug: str, page_id: int, title: str) -> None`
Renomme une page. Un titre vide (une fois `.strip()`-é) est
silencieusement ignoré — la page garde son titre précédent plutôt que de
se retrouver sans nom dans la bande d'onglets.
- **Retour** : aucun.
- **Exceptions** : aucune.
## `move_document_page(slug: str, page_id: int, new_index: int) -> None`
Réordonne une page dans la bande d'onglets (glisser-déposer) —
renumérote intégralement `order_index` sur toutes les pages, même
principe que `document_engine.move_document_element` (jamais un
décalage un par un).
- **Retour** : aucun.
- **Exceptions** : aucune.
## `delete_document_page(slug: str, page_id: int) -> None`
Supprime une page ET ses éléments (`DELETE FROM _document_elements
WHERE page_id = ?` explicite — la contrainte `FOREIGN KEY ... ON DELETE
CASCADE` n'existe que pour les supports créés après l'ajout des pages,
voir `db/supports/ensure_document_pages_schema.py` pour les anciens).
Ne refuse JAMAIS de supprimer la dernière page restante — cette règle
est posée par l'appelant, pas par cette fonction bas niveau (même
découpage que `routes/game/screens/screen_delete.py` côté jeu, où le
garde-fou vit aussi dans la route).
- **Retour** : aucun.
- **Exceptions** : aucune.
@@ -0,0 +1,17 @@
from db.supports import connect_support, ensure_document_pages_schema
def update_document_page(slug: str, page_id: int, title: str) -> None:
"""Renomme une page — un titre vide (une fois `.strip()`-é) est
silencieusement ignoré (la page garde son titre précédent) plutôt que
de la faire disparaître de la bande d'onglets, même philosophie que
les sanitize_X_config de document_engine/labels/ : jamais un état
structurel invalide."""
ensure_document_pages_schema(slug)
clean_title = title.strip()
if not clean_title:
return
conn = connect_support(slug)
conn.execute("UPDATE _document_pages SET title = ? WHERE id = ?", (clean_title, page_id))
conn.commit()
conn.close()