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
+2
View File
@@ -6,6 +6,7 @@ indépendance."""
from .create_support import create_support
from .delete_support import delete_support
from .ensure_document_pages_schema import ensure_document_pages_schema
from .list_supports import list_supports
from .support_connection import connect_support, install_support_teardown_safety_net
from .support_dir import support_dir
@@ -18,6 +19,7 @@ __all__ = [
"connect_support",
"create_support",
"delete_support",
"ensure_document_pages_schema",
"install_support_teardown_safety_net",
"list_supports",
"split_slug",
+18 -4
View File
@@ -10,10 +10,15 @@ def create_support(name: str, owner_folder: str) -> str:
"""Crée le dossier du support (projects/<owner_folder>/_supports/
<project_part>/) et sa base support.db dédiée — mirroir de
db/games/create_game.py pour la mécanique de dossier/unicité de nom,
mais un schéma entièrement différent (voir document_engine/ : un seul
document par support, pas d'écrans/objets de scène). owner_folder
n'est jamais optionnel ici (contrairement à create_game) : un support
n'existe pas sans compte propriétaire."""
mais un schéma entièrement différent (voir document_engine/ : un
support est composé de PAGES — _document_pages —, chacune portant son
propre flux d'éléments via _document_elements.page_id, jamais
d'écrans/objets de scène comme côté jeu). Une première page ("Page 1")
est toujours créée ici : un support n'existe jamais sans au moins une
page (voir document_engine/pages/delete_document_page.py, qui refuse
de supprimer la dernière). owner_folder n'est jamais optionnel ici
(contrairement à create_game) : un support n'existe pas sans compte
propriétaire."""
project_part = slugify(name)
base = project_part
n = 2
@@ -30,8 +35,16 @@ def create_support(name: str, owner_folder: str) -> str:
"""
CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE _document_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Page 1',
order_index INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE _document_elements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES _document_pages(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES _document_elements(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
order_index INTEGER NOT NULL DEFAULT 0,
@@ -41,6 +54,7 @@ def create_support(name: str, owner_folder: str) -> str:
"""
)
conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,))
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
conn.commit()
conn.close()
return slug
@@ -0,0 +1,37 @@
from .support_connection import connect_support
def ensure_document_pages_schema(slug: str) -> None:
"""Migration légère (même principe que db/global_vars/
ensure_global_vars_schema.py) : crée _document_pages si absente, et
ajoute page_id à _document_elements si absent — un support créé avant
le système de pages avait un seul document implicite ; ses éléments
existants deviennent tous les enfants d'une page "Page 1" créée ici
automatiquement (comportement le plus proche de l'ancien : un seul
document visible, qui devient simplement sa première page)."""
conn = connect_support(slug)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _document_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Page 1',
order_index INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
page_count = conn.execute("SELECT COUNT(*) AS n FROM _document_pages").fetchone()["n"]
if page_count == 0:
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_elements)").fetchall()}
if "page_id" not in cols:
first_page_id = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()["id"]
# SQLite autorise ADD COLUMN avec une valeur par défaut CONSTANTE
# (jamais une contrainte REFERENCES ici, même pragmatisme que
# db/rows/ensure_player_id_column.py : la contrainte de clé
# étrangère n'existe que pour les supports créés APRÈS ce
# changement, via create_support.py).
conn.execute(f"ALTER TABLE _document_elements ADD COLUMN page_id INTEGER NOT NULL DEFAULT {first_page_id}")
conn.commit()
conn.close()
+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()
+4
View File
@@ -11,5 +11,9 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
document_element_move,
document_element_update,
document_new,
document_page_add,
document_page_delete,
document_page_move,
document_page_rename,
document_render,
)
+13 -1
View File
@@ -13,6 +13,14 @@ def document_edit(slug: str) -> str:
ses écrans -> éditeur de scène) : ici la carte "Mes formations" mène
directement ici.
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") : le canevas n'affiche au chargement que la
PREMIÈRE page (triée par order_index) — changer de page se fait
ensuite entièrement côté client via /document/<slug>/render?page_id=
(voir static/document/js/document-editor.js), jamais un rechargement
complet de cette route.
Les attributs de chaque élément sont revalidés (document_engine.
sanitize_element_attributes) avant d'atteindre le client — jamais les
valeurs brutes stockées telles quelles : un élément dont le schéma a
@@ -21,13 +29,17 @@ def document_edit(slug: str) -> str:
panneau Propriétés côté client, qui suppose la forme ACTUELLE (bug réel
constaté le 21/09/2026)."""
support = db.support_meta(slug)
pages = document_engine.list_document_pages(slug)
active_page = pages[0]
elements = [
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
for el in document_engine.list_document_elements(slug)
for el in document_engine.list_document_elements(slug, active_page["id"])
]
return render_template(
"document/document_edit.html",
support=support,
pages=pages,
active_page=active_page,
elements=elements,
rendered_document=document_engine.render_document(elements),
element_library=document_engine.ELEMENT_LIBRARY,
+14 -10
View File
@@ -10,25 +10,29 @@ 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."""
"""Ajoute un élément à une page précise — 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 DE CETTE MÊME
PAGE."""
kind = request.form.get("kind", "")
if kind not in document_engine.ELEMENT_KIND_LABELS:
return jsonify({"error": "type d'élément inconnu"}), 400
page_id = request.form.get("page_id", type=int)
if page_id is None or document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
parent_id = request.form.get("parent_id", type=int)
element_id = document_engine.add_document_element(slug, kind, parent_id=parent_id)
element_id = document_engine.add_document_element(slug, kind, page_id=page_id, 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):
for el in document_engine.list_document_elements(slug, page_id):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
+1 -1
View File
@@ -37,7 +37,7 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
"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):
for el in document_engine.list_document_elements(slug, element["page_id"]):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
+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>/pages/add", methods=["POST"])
def document_page_add(slug: str) -> Response:
"""Ajoute une page en fin de la bande d'onglets — appelée en AJAX
depuis le "+" de static/document/js/document-editor.js. Le client
bascule ensuite lui-même la page active sur cette nouvelle page (via
/document/<slug>/render?page_id=<id>), jamais un rendu HTML renvoyé
ici : une page neuve n'a aucun élément à rendre."""
payload = request.get_json(silent=True) or {}
title = payload.get("title")
page_id = document_engine.add_document_page(slug, title=title)
page = document_engine.get_document_page(slug, page_id)
return jsonify({"id": page_id, "title": page["title"] if page else title})
+20
View File
@@ -0,0 +1,20 @@
from flask import jsonify
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/<int:page_id>/delete", methods=["POST"])
def document_page_delete(slug: str, page_id: int) -> Response | tuple[Response, int]:
"""Supprime une page — refuse de supprimer la DERNIÈRE page restante
(un support a toujours au moins une page, voir document_engine/pages/
pages.md) : le garde-fou vit ici, à la route, pas dans
delete_document_page (même découpage que routes/game/screens/
screen_delete.py côté jeu, qui bloque aussi au niveau route)."""
if document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
if len(document_engine.list_document_pages(slug)) <= 1:
return jsonify({"error": "impossible de supprimer la dernière page"}), 400
document_engine.delete_document_page(slug, page_id)
return jsonify({"ok": True})
+18
View File
@@ -0,0 +1,18 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/<int:page_id>/move", methods=["POST"])
def document_page_move(slug: str, page_id: int) -> Response | tuple[Response, int]:
"""Réordonne une page dans la bande d'onglets — new_index calculé
côté client (glisser-déposer d'un onglet, voir static/document/js/
document-editor.js)."""
if document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
payload = request.get_json(force=True) or {}
new_index = int(payload.get("new_index", 0))
document_engine.move_document_page(slug, page_id, new_index)
return jsonify({"ok": True})
+20
View File
@@ -0,0 +1,20 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/<int:page_id>/rename", methods=["POST"])
def document_page_rename(slug: str, page_id: int) -> Response | tuple[Response, int]:
"""Renomme une page — renvoie le titre RÉELLEMENT persisté (jamais
celui envoyé tel quel) : update_document_page ignore silencieusement
un titre vide, le client doit refléter ce que le serveur a vraiment
gardé, même convention que document_element_update.py."""
if document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
payload = request.get_json(force=True) or {}
document_engine.update_document_page(slug, page_id, str(payload.get("title", "")))
page = document_engine.get_document_page(slug, page_id)
title = page["title"] if page else ""
return jsonify({"ok": True, "title": title})
+13 -8
View File
@@ -1,4 +1,4 @@
from flask import jsonify
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
@@ -6,19 +6,24 @@ 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").
def document_render(slug: str) -> Response | tuple[Response, int]:
"""Rendu HTML complet et à jour d'UNE PAGE du document (?page_id=) —
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"). Le canevas n'affiche jamais qu'une seule page à la
fois (voir document_engine/pages/), d'où ce paramètre.
`elements` est revalidé (document_engine.sanitize_element_attributes)
avant d'être renvoyé au client — même raison que routes/document/
document_edit.py : le panneau Propriétés reçoit `data.elements`
directement depuis cette route à chaque rafraîchissement du canevas."""
page_id = request.args.get("page_id", type=int)
if page_id is None or document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
elements = [
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
for el in document_engine.list_document_elements(slug)
for el in document_engine.list_document_elements(slug, page_id)
]
return jsonify({"html": document_engine.render_document(elements), "elements": elements})
+156 -1
View File
@@ -1156,6 +1156,160 @@ img.docImage {
text-align: center;
}
/* ---- Pilule de navigation entre PAGES en Mode Aperçu (retour
utilisateur du 21/09/2026 : "il faut implémenter un système de page",
navigation "page par page avec Suivant/Précédent"). Même position que
.docZoomPill ci-dessus (jamais affichées en même temps : docZoomPill
est masquée en Aperçu, voir plus bas) — .is-visible posée par
forgeDocUpdatePageNavPill (static/document/js/document-editor.js)
uniquement en Mode Aperçu ET s'il y a plus d'une page, jamais un
sélecteur CSS seul (une pilule "Page 1 / 1" n'aurait aucun sens). ---- */
.docPageNavPill {
display: none;
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--doc-card);
border: 1px solid var(--doc-border);
border-radius: 999px;
height: 36px;
padding: 0 10px;
align-items: center;
gap: 10px;
box-shadow: 0 2px 6px rgb(0 0 0 / 30%);
}
.docPageNavPill.is-visible {
display: flex;
}
.docPageNavPill button {
width: 26px;
height: 26px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--doc-text);
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.docPageNavPill button:hover:not(:disabled) {
background: var(--doc-bg-2);
}
.docPageNavPill button:disabled {
opacity: 0.35;
cursor: default;
}
.docPageNavPill span {
font-size: 12.5px;
font-weight: 700;
color: var(--doc-muted);
white-space: nowrap;
}
/* ---- Bande d'onglets des PAGES (au-dessus du canevas, jamais un
panneau latéral — voir retour utilisateur du 21/09/2026). Masquée en
Mode Aperçu (voir la règle .docEditor3--preview plus bas), qui a sa
propre navigation via .docPageNavPill ci-dessus. ---- */
.docPageTabs {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 20px 0;
background: var(--doc-bg-2);
border-bottom: 1px solid var(--doc-border);
overflow-x: auto;
flex-shrink: 0;
}
.docPageTab {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px 7px 14px;
border: 1px solid var(--doc-border);
border-bottom: none;
border-radius: 8px 8px 0 0;
background: transparent;
color: var(--doc-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.docPageTab:hover {
color: var(--doc-text);
border-color: var(--doc-accent);
}
.docPageTab.is-active {
background: var(--doc-bg);
color: var(--doc-text);
}
.docPageTab.is-dragging {
opacity: 0.4;
}
.docPageTabTitle {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
}
.docPageTabTitle[contenteditable="true"] {
outline: none;
border-bottom: 1px solid var(--doc-accent);
cursor: text;
user-select: text;
}
.docPageTabDelete {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
font-size: 10px;
color: var(--doc-muted);
}
.docPageTabDelete:hover {
background: var(--doc-quiz-danger-bg);
color: var(--doc-quiz-danger-text);
}
.docPageTabs.docPageTabs--single .docPageTabDelete {
visibility: hidden;
}
.docPageTabAdd {
flex-shrink: 0;
width: 28px;
height: 28px;
margin-bottom: 6px;
border-radius: 8px;
border: 1px dashed var(--doc-border);
background: transparent;
color: var(--doc-muted);
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
.docPageTabAdd:hover {
color: var(--doc-text);
border-color: var(--doc-accent);
}
/* ---- RIGHT SIDEBAR : Propriétés ---- */
.docField {
display: flex;
@@ -1396,7 +1550,8 @@ img.docImage {
.docEditor3.docEditor3--preview .docSidebarLeft,
.docEditor3.docEditor3--preview .docSidebarRight,
.docEditor3.docEditor3--preview .docBottomNav,
.docEditor3.docEditor3--preview .docZoomPill {
.docEditor3.docEditor3--preview .docZoomPill,
.docEditor3.docEditor3--preview .docPageTabs {
display: none;
}
+263 -1
View File
@@ -49,8 +49,15 @@ function forgeDocRebuildIndex() {
* ------------------------------------------------------------------- */
function forgeDocApiAdd(kind, parentId) {
// page_id lu depuis l'état ambiant (window.forgeDocState.activePageId),
// jamais un paramètre explicite — même principe que le slug déjà lu
// ainsi juste en dessous : tout appelant de cette fonction ajoute par
// définition à "la page actuellement affichée", inutile de le faire
// remonter dans chacun des ~10 sites d'appel (glisser-déposer, moteur
// de layout, Annuler/Rétablir...).
const body = new URLSearchParams();
body.set('kind', kind);
body.set('page_id', String(window.forgeDocState.activePageId));
if (parentId !== null && parentId !== undefined) body.set('parent_id', String(parentId));
return fetch(`/document/${window.forgeDocState.slug}/elements/add`, { method: 'POST', body })
.then((r) => r.json());
@@ -78,7 +85,8 @@ function forgeDocApiDelete(id) {
}
function forgeDocApiRender() {
return fetch(`/document/${window.forgeDocState.slug}/render`).then((r) => r.json());
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
return fetch(url).then((r) => r.json());
}
/* ---------------------------------------------------------------------
@@ -401,6 +409,254 @@ function forgeDocBindCanvasDropZone() {
});
}
/* ---------------------------------------------------------------------
* Pages — un support est composé de plusieurs pages (retour utilisateur
* du 21/09/2026 : "il faut implémenter un système de page"), chacune un
* document indépendant affiché SEUL sur le canevas à la fois. Bande
* d'ONGLETS au-dessus du canevas pour créer/renommer (double-clic)/
* réordonner (glisser)/supprimer (voir templates/document/
* document_edit.html) — jamais un panneau latéral (retour utilisateur).
* Changer de page VIDE la pile Annuler/Rétablir (voir forgeDocSwitchPage)
* : une commande empilée sur une autre page rejouerait un appel AJAX
* ciblant des éléments qui ne sont plus affichés, aucun sens à la garder.
* ------------------------------------------------------------------- */
function forgeDocApiPageAdd(title) {
return fetch(`/document/${window.forgeDocState.slug}/pages/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(title ? { title } : {}),
}).then((r) => r.json());
}
function forgeDocApiPageRename(pageId, title) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}).then((r) => r.json());
}
function forgeDocApiPageDelete(pageId) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/delete`, { method: 'POST' })
.then((r) => r.json());
}
function forgeDocApiPageMove(pageId, newIndex) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_index: newIndex }),
}).then((r) => r.json());
}
function forgeDocOrderedPages() {
return [...window.forgeDocState.pages].sort((a, b) => a.order_index - b.order_index);
}
function forgeDocUpdatePageNavPill() {
const pill = document.getElementById('docPageNavPill');
if (!pill) return;
const pages = forgeDocOrderedPages();
const show = window.forgeDocState.previewMode && pages.length > 1;
pill.classList.toggle('is-visible', show);
if (!show) return;
const index = pages.findIndex((p) => p.id === window.forgeDocState.activePageId);
document.getElementById('docPageNavLabel').textContent = `Page ${index + 1} / ${pages.length}`;
document.getElementById('docPagePrevBtn').disabled = index <= 0;
document.getElementById('docPageNextBtn').disabled = index >= pages.length - 1;
}
function forgeDocSwitchPage(pageId) {
if (pageId === window.forgeDocState.activePageId) return Promise.resolve();
window.forgeDocState.activePageId = pageId;
window.forgeDocState.undoStack = [];
window.forgeDocState.redoStack = [];
forgeDocUpdateHistoryButtons();
window.forgeDocState.selectedId = null;
forgeDocRenderProps(null);
forgeDocRenderPageTabs();
forgeDocUpdatePageNavPill();
return forgeDocRefreshCanvas();
}
function forgeDocAddPage() {
return forgeDocApiPageAdd().then((result) => {
window.forgeDocState.pages.push({
id: result.id, title: result.title, order_index: window.forgeDocState.pages.length,
});
return forgeDocSwitchPage(result.id);
});
}
function forgeDocRenamePage(pageId, title) {
return forgeDocApiPageRename(pageId, title).then((result) => {
const page = window.forgeDocState.pages.find((p) => p.id === pageId);
if (page) page.title = result.title;
forgeDocRenderPageTabs();
});
}
function forgeDocDeletePage(pageId) {
// Filet de sécurité client (en plus du refus serveur, voir
// routes/document/document_page_delete.py) : jamais même tenter de
// supprimer la dernière page restante.
if (window.forgeDocState.pages.length <= 1) return Promise.resolve();
return forgeDocApiPageDelete(pageId).then((result) => {
if (result.error) {
alert(result.error);
return Promise.resolve();
}
window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId);
if (window.forgeDocState.activePageId === pageId) {
return forgeDocSwitchPage(forgeDocOrderedPages()[0].id);
}
forgeDocRenderPageTabs();
return Promise.resolve();
});
}
function forgeDocSelectAllText(el) {
const range = document.createRange();
range.selectNodeContents(el);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function forgeDocStartRenamingPageTab(titleEl) {
const tab = titleEl.closest('.docPageTab');
const pageId = Number(tab.dataset.pageId);
const original = titleEl.textContent;
titleEl.setAttribute('contenteditable', 'true');
titleEl.focus();
forgeDocSelectAllText(titleEl);
function stopEditing() {
titleEl.removeAttribute('contenteditable');
titleEl.removeEventListener('blur', onBlur);
titleEl.removeEventListener('keydown', onKeydown);
}
function onBlur() {
const newTitle = titleEl.textContent.trim();
stopEditing();
if (!newTitle || newTitle === original) {
titleEl.textContent = original;
return;
}
forgeDocRenamePage(pageId, newTitle);
}
function onKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
titleEl.blur();
} else if (e.key === 'Escape') {
e.preventDefault();
titleEl.textContent = original;
stopEditing();
}
}
titleEl.addEventListener('blur', onBlur);
titleEl.addEventListener('keydown', onKeydown);
}
function forgeDocRenderPageTabs() {
const container = document.getElementById('docPageTabs');
if (!container) return;
const pages = forgeDocOrderedPages();
container.classList.toggle('docPageTabs--single', pages.length <= 1);
const tabsHtml = pages.map((p) => `
<button type="button"
class="docPageTab ${p.id === window.forgeDocState.activePageId ? 'is-active' : ''}"
data-page-id="${p.id}"
draggable="true">
<span class="docPageTabTitle">${forgeDocEscapeHtml(p.title)}</span>
<span class="docPageTabDelete" data-page-id="${p.id}" title="Supprimer cette page">✕</span>
</button>
`).join('');
container.innerHTML = `${tabsHtml}<button type="button" class="docPageTabAdd" id="docPageTabAdd" aria-label="Ajouter une page" title="Ajouter une page">+</button>`;
container.querySelectorAll('.docPageTab').forEach((tab) => {
tab.addEventListener('click', (e) => {
if (e.target.classList.contains('docPageTabDelete')) return;
forgeDocSwitchPage(Number(tab.dataset.pageId));
});
});
container.querySelectorAll('.docPageTabTitle').forEach((titleEl) => {
titleEl.addEventListener('dblclick', (e) => {
e.stopPropagation();
forgeDocStartRenamingPageTab(titleEl);
});
});
container.querySelectorAll('.docPageTabDelete').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocDeletePage(Number(btn.dataset.pageId));
});
});
document.getElementById('docPageTabAdd').addEventListener('click', () => forgeDocAddPage());
}
function forgeDocBindPageTabsDrag() {
// Délégation sur le CONTENEUR (jamais les onglets eux-mêmes, recréés à
// chaque forgeDocRenderPageTabs) : un seul jeu de listeners pour toute
// la session, même principe que forgeDocMotsBindGlobalMouseup.
const container = document.getElementById('docPageTabs');
let draggedId = null;
container.addEventListener('dragstart', (e) => {
const tab = e.target.closest('.docPageTab');
if (!tab) return;
draggedId = Number(tab.dataset.pageId);
tab.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
});
container.addEventListener('dragend', (e) => {
const tab = e.target.closest('.docPageTab');
if (tab) tab.classList.remove('is-dragging');
});
container.addEventListener('dragover', (e) => {
if (draggedId !== null) e.preventDefault();
});
container.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedId === null) return;
const targetTab = e.target.closest('.docPageTab');
const ordered = forgeDocOrderedPages();
const newIndex = targetTab
? ordered.findIndex((p) => p.id === Number(targetTab.dataset.pageId))
: ordered.length - 1;
const movedId = draggedId;
draggedId = null;
forgeDocApiPageMove(movedId, newIndex).then(() => {
const moved = window.forgeDocState.pages.find((p) => p.id === movedId);
const rest = window.forgeDocState.pages
.filter((p) => p.id !== movedId)
.sort((a, b) => a.order_index - b.order_index);
rest.splice(newIndex, 0, moved);
rest.forEach((p, i) => { p.order_index = i; });
window.forgeDocState.pages = rest;
forgeDocRenderPageTabs();
});
});
}
function forgeDocBindPageNavPill() {
document.getElementById('docPagePrevBtn').addEventListener('click', () => {
const pages = forgeDocOrderedPages();
const index = pages.findIndex((p) => p.id === window.forgeDocState.activePageId);
if (index > 0) forgeDocSwitchPage(pages[index - 1].id);
});
document.getElementById('docPageNextBtn').addEventListener('click', () => {
const pages = forgeDocOrderedPages();
const index = pages.findIndex((p) => p.id === window.forgeDocState.activePageId);
if (index < pages.length - 1) forgeDocSwitchPage(pages[index + 1].id);
});
}
/* ---------------------------------------------------------------------
* Formes libres — position absolue, glissées à la souris (pas de HTML5
* DnD, pas de logique de rangée) avec grille d'accroche réelle.
@@ -2500,6 +2756,7 @@ function forgeDocBindPreviewToggle() {
document.getElementById('docPreviewBtn').addEventListener('click', () => {
window.forgeDocState.previewMode = !window.forgeDocState.previewMode;
document.getElementById('docEditor3').classList.toggle('docEditor3--preview', window.forgeDocState.previewMode);
forgeDocUpdatePageNavPill();
});
}
@@ -2579,6 +2836,8 @@ function forgeDocInit() {
slug: window.FORGE_DOCUMENT.slug,
elements: window.FORGE_DOCUMENT.elements,
elementsById: {},
pages: window.FORGE_DOCUMENT.pages,
activePageId: window.FORGE_DOCUMENT.activePageId,
selectedId: null,
undoStack: [],
redoStack: [],
@@ -2600,6 +2859,9 @@ function forgeDocInit() {
forgeDocBindMobileNav();
forgeDocBindKeyboardShortcuts();
forgeDocBindScenarioTreeModal();
forgeDocRenderPageTabs();
forgeDocBindPageTabsDrag();
forgeDocBindPageNavPill();
forgeDocUpdateHistoryButtons();
document.getElementById('docUndoBtn').addEventListener('click', forgeDocUndo);
+20 -1
View File
@@ -92,6 +92,18 @@
aria-label="Changer de thème"></button>
</div>
</div>
<div class="docPageTabs" id="docPageTabs">
{% for page in pages %}
<button type="button"
class="docPageTab {{ 'is-active' if page.id == active_page.id else '' }}"
data-page-id="{{ page.id }}"
draggable="true">
<span class="docPageTabTitle">{{ page.title }}</span>
<span class="docPageTabDelete" data-page-id="{{ page.id }}" title="Supprimer cette page">✕</span>
</button>
{% endfor %}
<button type="button" class="docPageTabAdd" id="docPageTabAdd" aria-label="Ajouter une page" title="Ajouter une page">+</button>
</div>
<div class="docBodyWrap">
<div class="docSidebarClose" id="docCloseLeft">← Retour au document</div>
<div class="docSidebar docSidebarLeft" id="docSidebarLeft">
@@ -135,6 +147,11 @@
<span id="docZoomVal">100%</span>
<button type="button" id="docZoomIn" aria-label="Zoom +">+</button>
</div>
<div class="docPageNavPill" id="docPageNavPill">
<button type="button" id="docPagePrevBtn" aria-label="Page précédente">‹</button>
<span id="docPageNavLabel"></span>
<button type="button" id="docPageNextBtn" aria-label="Page suivante">›</button>
</div>
</div>
<div class="docSidebarClose" id="docCloseRight">← Retour au document</div>
<div class="docSidebar docSidebarRight" id="docSidebarRight">
@@ -166,7 +183,9 @@
window.FORGE_DOCUMENT = {
slug: {{ support.slug|tojson }},
elements: {{ elements|tojson }},
elementKindLabels: {{ element_kind_labels|tojson }}
elementKindLabels: {{ element_kind_labels|tojson }},
pages: {{ pages|tojson }},
activePageId: {{ active_page.id|tojson }}
};
</script>
<script src="{{ url_for('static', filename='document/js/document-editor.js') }}"></script>
+49 -33
View File
@@ -2,7 +2,11 @@
— sans Flask, directement contre le support.db créé par db.create_support.
Couvre : CRUD complet, rangées (parent_id), réinsertion (move_document_element
vers une nouvelle rangée/position), suppression en cascade d'une rangée avec
enfants, et les attributs de position/taille/rotation d'une forme."""
enfants, et les attributs de position/taille/rotation d'une forme.
Chaque élément appartient désormais à une page (voir document_engine/
pages/) — _new_support renvoie (slug, page_id) de la première page créée
par défaut, réutilisée par tous les tests de ce fichier."""
from typing import Any
@@ -10,35 +14,37 @@ import db
import document_engine as doc_engine
def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> str:
def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> tuple[str, int]:
slug = db.create_support(name, owner_folder="90")
tmp_support_slug_cleanup(slug)
return slug
page_id = doc_engine.list_document_pages(slug)[0]["id"]
return slug, page_id
def test_add_and_get_document_element_uses_default_attributes(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "titre")
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
assert element["kind"] == "titre"
assert element["page_id"] == page_id
assert element["parent_id"] is None
assert element["attributes"]["content"] == "Nouveau titre"
def test_get_document_element_returns_none_for_missing_id(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
slug, _page_id = _new_support(tmp_support_slug_cleanup)
assert doc_engine.get_document_element(slug, 999) is None
def test_list_document_elements_groups_row_children_together(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row")
top_titre_id = doc_engine.add_document_element(slug, "titre")
child_a_id = doc_engine.add_document_element(slug, "paragraphe", parent_id=row_id)
child_b_id = doc_engine.add_document_element(slug, "image", parent_id=row_id)
slug, page_id = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row", page_id=page_id)
top_titre_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
child_a_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page_id, parent_id=row_id)
child_b_id = doc_engine.add_document_element(slug, "image", page_id=page_id, parent_id=row_id)
elements = doc_engine.list_document_elements(slug)
elements = doc_engine.list_document_elements(slug, page_id)
ids_in_order = [el["id"] for el in elements]
# Les deux top-niveau (row_id, top_titre_id) groupés avant les deux
@@ -48,9 +54,19 @@ def test_list_document_elements_groups_row_children_together(tmp_support_slug_cl
assert ids_in_order.index(child_a_id) < ids_in_order.index(child_b_id)
def test_list_document_elements_only_returns_the_given_page(tmp_support_slug_cleanup: Any) -> None:
slug, page1_id = _new_support(tmp_support_slug_cleanup)
page2_id = doc_engine.add_document_page(slug)
el1_id = doc_engine.add_document_element(slug, "titre", page_id=page1_id)
el2_id = doc_engine.add_document_element(slug, "titre", page_id=page2_id)
assert [el["id"] for el in doc_engine.list_document_elements(slug, page1_id)] == [el1_id]
assert [el["id"] for el in doc_engine.list_document_elements(slug, page2_id)] == [el2_id]
def test_update_document_element_attributes_replaces_whole_json(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "paragraphe")
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page_id)
doc_engine.update_document_element_attributes(slug, element_id, {"content": "Texte modifié", "style": "legende"})
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
@@ -58,8 +74,8 @@ def test_update_document_element_attributes_replaces_whole_json(tmp_support_slug
def test_shape_default_attributes_carry_real_position_and_size(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "rectangle")
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "rectangle", page_id=page_id)
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
attrs = element["attributes"]
@@ -83,23 +99,23 @@ def test_shape_default_attributes_carry_real_position_and_size(tmp_support_slug_
def test_move_document_element_reorders_within_same_parent(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
first_id = doc_engine.add_document_element(slug, "titre")
second_id = doc_engine.add_document_element(slug, "paragraphe")
third_id = doc_engine.add_document_element(slug, "image")
slug, page_id = _new_support(tmp_support_slug_cleanup)
first_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
second_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page_id)
third_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.move_document_element(slug, third_id, None, 0)
elements = doc_engine.list_document_elements(slug)
elements = doc_engine.list_document_elements(slug, page_id)
top_level = [el for el in elements if el["parent_id"] is None]
ordered_ids = [el["id"] for el in sorted(top_level, key=lambda el: el["order_index"])]
assert ordered_ids == [third_id, first_id, second_id]
def test_move_document_element_reparents_into_a_row(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row")
lone_titre_id = doc_engine.add_document_element(slug, "titre")
slug, page_id = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row", page_id=page_id)
lone_titre_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
doc_engine.move_document_element(slug, lone_titre_id, row_id, 0)
@@ -110,9 +126,9 @@ def test_move_document_element_reparents_into_a_row(tmp_support_slug_cleanup: An
def test_delete_document_element_cascades_to_row_children(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row")
child_id = doc_engine.add_document_element(slug, "titre", parent_id=row_id)
slug, page_id = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row", page_id=page_id)
child_id = doc_engine.add_document_element(slug, "titre", page_id=page_id, parent_id=row_id)
doc_engine.delete_document_element(slug, row_id)
@@ -121,17 +137,17 @@ def test_delete_document_element_cascades_to_row_children(tmp_support_slug_clean
def test_delete_document_element_on_missing_id_is_a_noop(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
slug, _page_id = _new_support(tmp_support_slug_cleanup)
doc_engine.delete_document_element(slug, 999)
def test_render_document_includes_row_children_and_shape_position(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row")
doc_engine.add_document_element(slug, "titre", parent_id=row_id)
rect_id = doc_engine.add_document_element(slug, "rectangle")
slug, page_id = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row", page_id=page_id)
doc_engine.add_document_element(slug, "titre", page_id=page_id, parent_id=row_id)
rect_id = doc_engine.add_document_element(slug, "rectangle", page_id=page_id)
html = doc_engine.render_document(doc_engine.list_document_elements(slug))
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert 'data-kind="row"' in html
assert 'data-kind="titre"' in html
@@ -0,0 +1,95 @@
"""Routes HTTP des pages d'un support de formation (routes/document/
document_page_*.py) — retour utilisateur du 21/09/2026 : "il faut
implémenter un système de page". Un support a toujours au moins une page
(créée par défaut avec le support, voir la fixture `support`)."""
from flask.testing import FlaskClient
import document_engine
def _page_id(support: str) -> int:
return int(document_engine.list_document_pages(support)[0]["id"])
def test_document_page_add_creates_a_second_page(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/pages/add", json={})
assert resp.status_code == 200
payload = resp.get_json()
assert payload["title"] == "Page 2"
pages = document_engine.list_document_pages(support)
assert [p["title"] for p in pages] == ["Page 1", "Page 2"]
assert pages[1]["id"] == payload["id"]
def test_document_page_add_accepts_a_custom_title(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/pages/add", json={"title": "Chapitre 2"})
assert resp.status_code == 200
assert resp.get_json()["title"] == "Chapitre 2"
def test_document_page_rename_persists_the_new_title(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
resp = client.post(f"/document/{support}/pages/{page_id}/rename", json={"title": "Introduction"})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "title": "Introduction"}
page = document_engine.get_document_page(support, page_id)
assert page is not None
assert page["title"] == "Introduction"
def test_document_page_rename_ignores_an_empty_title(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
resp = client.post(f"/document/{support}/pages/{page_id}/rename", json={"title": " "})
assert resp.status_code == 200
assert resp.get_json()["title"] == "Page 1"
def test_document_page_rename_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/pages/999/rename", json={"title": "X"}).status_code == 404
def test_document_page_delete_refuses_the_last_remaining_page(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
resp = client.post(f"/document/{support}/pages/{page_id}/delete")
assert resp.status_code == 400
assert "error" in resp.get_json()
assert document_engine.get_document_page(support, page_id) is not None
def test_document_page_delete_removes_a_non_last_page_and_its_elements(client: FlaskClient, support: str) -> None:
first_page_id = _page_id(support)
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
element_resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": second_page_id})
element_id = element_resp.get_json()["id"]
resp = client.post(f"/document/{support}/pages/{second_page_id}/delete")
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
assert document_engine.get_document_page(support, second_page_id) is None
assert document_engine.get_document_element(support, element_id) is None
assert [p["id"] for p in document_engine.list_document_pages(support)] == [first_page_id]
def test_document_page_delete_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
client.post(f"/document/{support}/pages/add", json={})
assert client.post(f"/document/{support}/pages/999/delete").status_code == 404
def test_document_page_move_reorders_the_tab_strip(client: FlaskClient, support: str) -> None:
first_page_id = _page_id(support)
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
third_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
resp = client.post(f"/document/{support}/pages/{third_page_id}/move", json={"new_index": 0})
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
ordered_ids = [p["id"] for p in document_engine.list_document_pages(support)]
assert ordered_ids == [third_page_id, first_page_id, second_page_id]
def test_document_page_move_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/pages/999/move", json={"new_index": 0}).status_code == 404
+45 -13
View File
@@ -1,6 +1,12 @@
"""Routes HTTP du support de formation (routes/document/) — création,
édition directe (sans page intermédiaire), CRUD des éléments en AJAX,
publication, et isolation par propriétaire (core/auth_guard.py)."""
publication, et isolation par propriétaire (core/auth_guard.py).
Chaque élément appartient désormais à une page (voir document_engine/
pages/ et test_document_pages_routes.py pour les routes dédiées aux
pages elles-mêmes) — _page_id() renvoie l'id de la page par défaut
("Page 1") créée avec chaque support de test, réutilisé par toutes les
routes d'élément ci-dessous qui exigent désormais un page_id explicite."""
from typing import Any
@@ -11,6 +17,10 @@ import db
import document_engine
def _page_id(support: str) -> int:
return int(document_engine.list_document_pages(support)[0]["id"])
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
resp = client.post("/documents/new", data={"name": "Sécurité incendie"}, follow_redirects=False)
assert resp.status_code == 302
@@ -29,7 +39,7 @@ def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre"})
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
assert resp.status_code == 200
payload = resp.get_json()
assert payload["kind"] == "titre"
@@ -37,7 +47,15 @@ def test_document_element_add_returns_rendered_html(client: FlaskClient, support
def test_document_element_add_rejects_unknown_kind(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/elements/add", data={"kind": "n-importe-quoi"}).status_code == 400
resp = client.post(
f"/document/{support}/elements/add", data={"kind": "n-importe-quoi", "page_id": _page_id(support)}
)
assert resp.status_code == 400
def test_document_element_add_rejects_a_missing_or_unknown_page(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).status_code == 404
assert client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": 999}).status_code == 404
def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskClient, support: str) -> None:
@@ -46,13 +64,14 @@ def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskCli
route — c'est le moteur de layout côté client qui en crée une à la
volée au moment d'un dépôt latéral (voir
static/document/js/document-editor.js)."""
resp = client.post(f"/document/{support}/elements/add", data={"kind": "row"})
resp = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": _page_id(support)})
assert resp.status_code == 200
assert resp.get_json()["kind"] == "row"
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"})
page_id = _page_id(support)
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id})
element_id = add_resp.get_json()["id"]
resp = client.post(
@@ -62,7 +81,7 @@ def test_document_element_update_persists_attributes(client: FlaskClient, suppor
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre"})
reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
assert reread.status_code == 200
@@ -72,7 +91,7 @@ def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskCli
(5e) doit être tronqué, et la réponse renvoie les attributs
RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le
client ne dérive jamais de la vérité serveur."""
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz"})
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)})
element_id = add_resp.get_json()["id"]
resp = client.post(
@@ -94,7 +113,9 @@ def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskCli
def test_document_element_update_sanitizes_association_config_on_write(client: FlaskClient, support: str) -> None:
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "association"})
add_resp = client.post(
f"/document/{support}/elements/add", data={"kind": "association", "page_id": _page_id(support)}
)
element_id = add_resp.get_json()["id"]
resp = client.post(
@@ -115,7 +136,7 @@ def test_document_element_update_sanitizes_association_config_on_write(client: F
def test_document_element_update_sanitizes_memory_config_on_write(client: FlaskClient, support: str) -> None:
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "memory"})
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "memory", "page_id": _page_id(support)})
element_id = add_resp.get_json()["id"]
resp = client.post(
@@ -151,7 +172,8 @@ def test_document_edit_sanitizes_stale_scenario_attributes_from_an_old_schema(
écrire ce schéma obsolète tel quel, en contournant volontairement le
sanitize de la route d'update — pour simuler une ligne réellement
ancienne en base, jamais nettoyée depuis."""
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "scenario"})
page_id = _page_id(support)
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "scenario", "page_id": page_id})
element_id = add_resp.get_json()["id"]
stale_attributes = {
"theme_color": "#ff5f2e",
@@ -167,7 +189,7 @@ def test_document_edit_sanitizes_stale_scenario_attributes_from_an_old_schema(
edit_resp = client.get(f"/document/{support}/edit")
assert edit_resp.status_code == 200
render_resp = client.get(f"/document/{support}/render")
render_resp = client.get(f"/document/{support}/render", query_string={"page_id": page_id})
assert render_resp.status_code == 200
rendered_element = next(el for el in render_resp.get_json()["elements"] if el["id"] == element_id)
# Le vieux schéma n'a ni "title" ni "nodes" valides : sanitize_scenario_config
@@ -177,9 +199,19 @@ def test_document_edit_sanitizes_stale_scenario_attributes_from_an_old_schema(
assert rendered_element["attributes"] == document_engine.DEFAULT_SCENARIO_CONFIG
def test_document_render_rejects_a_missing_or_unknown_page(client: FlaskClient, support: str) -> None:
assert client.get(f"/document/{support}/render").status_code == 404
assert client.get(f"/document/{support}/render", query_string={"page_id": 999}).status_code == 404
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"]
second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"]
page_id = _page_id(support)
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}).get_json()[
"id"
]
second_id = client.post(
f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id}
).get_json()["id"]
move_resp = client.post(
f"/document/{support}/elements/{second_id}/move",