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
@@ -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 = ?",