From ecd483f352bf70e07146a009a48f46e7159eb195 Mon Sep 17 00:00:00 2001 From: william Date: Mon, 21 Sep 2026 13:24:23 +0200 Subject: [PATCH] Implemente un systeme de pages pour le support de formation 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 --- db/supports/__init__.py | 2 + db/supports/create_support.py | 22 +- db/supports/ensure_document_pages_schema.py | 37 +++ document_engine/__init__.py | 26 +- .../elements/add_document_element.py | 24 +- .../elements/delete_document_element.py | 3 +- document_engine/elements/elements.md | 29 +- .../elements/get_document_element.py | 3 +- .../elements/list_document_elements.py | 21 +- .../elements/move_document_element.py | 8 +- .../update_document_element_attributes.py | 3 +- document_engine/pages/add_document_page.py | 23 ++ document_engine/pages/delete_document_page.py | 18 ++ document_engine/pages/get_document_page.py | 11 + document_engine/pages/list_document_pages.py | 16 ++ document_engine/pages/move_document_page.py | 20 ++ document_engine/pages/pages.md | 56 ++++ document_engine/pages/update_document_page.py | 17 ++ routes/document/__init__.py | 4 + routes/document/document_edit.py | 14 +- routes/document/document_element_add.py | 24 +- routes/document/document_element_update.py | 2 +- routes/document/document_page_add.py | 19 ++ routes/document/document_page_delete.py | 20 ++ routes/document/document_page_move.py | 18 ++ routes/document/document_page_rename.py | 20 ++ routes/document/document_render.py | 21 +- static/document/document-editor.css | 157 ++++++++++- static/document/js/document-editor.js | 264 +++++++++++++++++- templates/document/document_edit.html | 21 +- tests/document/test_document_elements.py | 82 +++--- tests/document/test_document_pages_routes.py | 95 +++++++ tests/document/test_document_routes.py | 58 +++- 33 files changed, 1048 insertions(+), 110 deletions(-) create mode 100644 db/supports/ensure_document_pages_schema.py create mode 100644 document_engine/pages/add_document_page.py create mode 100644 document_engine/pages/delete_document_page.py create mode 100644 document_engine/pages/get_document_page.py create mode 100644 document_engine/pages/list_document_pages.py create mode 100644 document_engine/pages/move_document_page.py create mode 100644 document_engine/pages/pages.md create mode 100644 document_engine/pages/update_document_page.py create mode 100644 routes/document/document_page_add.py create mode 100644 routes/document/document_page_delete.py create mode 100644 routes/document/document_page_move.py create mode 100644 routes/document/document_page_rename.py create mode 100644 tests/document/test_document_pages_routes.py diff --git a/db/supports/__init__.py b/db/supports/__init__.py index 3f7d72d4..281303da 100644 --- a/db/supports/__init__.py +++ b/db/supports/__init__.py @@ -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", diff --git a/db/supports/create_support.py b/db/supports/create_support.py index c091c0c9..93140aa1 100644 --- a/db/supports/create_support.py +++ b/db/supports/create_support.py @@ -10,10 +10,15 @@ def create_support(name: str, owner_folder: str) -> str: """Crée le dossier du support (projects//_supports/ /) 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 diff --git a/db/supports/ensure_document_pages_schema.py b/db/supports/ensure_document_pages_schema.py new file mode 100644 index 00000000..920396e6 --- /dev/null +++ b/db/supports/ensure_document_pages_schema.py @@ -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() diff --git a/document_engine/__init__.py b/document_engine/__init__.py index aad973ee..49d96b3c 100644 --- a/document_engine/__init__.py +++ b/document_engine/__init__.py @@ -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", ] diff --git a/document_engine/elements/add_document_element.py b/document_engine/elements/add_document_element.py index 5873f484..daeabf0d 100644 --- a/document_engine/elements/add_document_element.py +++ b/document_engine/elements/add_document_element.py @@ -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() diff --git a/document_engine/elements/delete_document_element.py b/document_engine/elements/delete_document_element.py index c6e8f0b1..244f34c6 100644 --- a/document_engine/elements/delete_document_element.py +++ b/document_engine/elements/delete_document_element.py @@ -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() diff --git a/document_engine/elements/elements.md b/document_engine/elements/elements.md index b302dd52..7f30954d 100644 --- a/document_engine/elements/elements.md +++ b/document_engine/elements/elements.md @@ -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 diff --git a/document_engine/elements/get_document_element.py b/document_engine/elements/get_document_element.py index 48b4135b..abf3d0fc 100644 --- a/document_engine/elements/get_document_element.py +++ b/document_engine/elements/get_document_element.py @@ -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() diff --git a/document_engine/elements/list_document_elements.py b/document_engine/elements/list_document_elements.py index 01511683..f10d2657 100644 --- a/document_engine/elements/list_document_elements.py +++ b/document_engine/elements/list_document_elements.py @@ -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: diff --git a/document_engine/elements/move_document_element.py b/document_engine/elements/move_document_element.py index 4ef634dd..d1cea9a8 100644 --- a/document_engine/elements/move_document_element.py +++ b/document_engine/elements/move_document_element.py @@ -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: diff --git a/document_engine/elements/update_document_element_attributes.py b/document_engine/elements/update_document_element_attributes.py index 9d817358..caad2539 100644 --- a/document_engine/elements/update_document_element_attributes.py +++ b/document_engine/elements/update_document_element_attributes.py @@ -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 = ?", diff --git a/document_engine/pages/add_document_page.py b/document_engine/pages/add_document_page.py new file mode 100644 index 00000000..cc38e103 --- /dev/null +++ b/document_engine/pages/add_document_page.py @@ -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 diff --git a/document_engine/pages/delete_document_page.py b/document_engine/pages/delete_document_page.py new file mode 100644 index 00000000..531afb71 --- /dev/null +++ b/document_engine/pages/delete_document_page.py @@ -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() diff --git a/document_engine/pages/get_document_page.py b/document_engine/pages/get_document_page.py new file mode 100644 index 00000000..9561b480 --- /dev/null +++ b/document_engine/pages/get_document_page.py @@ -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 diff --git a/document_engine/pages/list_document_pages.py b/document_engine/pages/list_document_pages.py new file mode 100644 index 00000000..e51d5a7f --- /dev/null +++ b/document_engine/pages/list_document_pages.py @@ -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] diff --git a/document_engine/pages/move_document_page.py b/document_engine/pages/move_document_page.py new file mode 100644 index 00000000..ca1f4fa9 --- /dev/null +++ b/document_engine/pages/move_document_page.py @@ -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() diff --git a/document_engine/pages/pages.md b/document_engine/pages/pages.md new file mode 100644 index 00000000..e8959ea7 --- /dev/null +++ b/document_engine/pages/pages.md @@ -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. diff --git a/document_engine/pages/update_document_page.py b/document_engine/pages/update_document_page.py new file mode 100644 index 00000000..1f827a04 --- /dev/null +++ b/document_engine/pages/update_document_page.py @@ -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() diff --git a/routes/document/__init__.py b/routes/document/__init__.py index ed93d747..7d324cf9 100644 --- a/routes/document/__init__.py +++ b/routes/document/__init__.py @@ -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, ) diff --git a/routes/document/document_edit.py b/routes/document/document_edit.py index 4cb589ec..c9f57f18 100644 --- a/routes/document/document_edit.py +++ b/routes/document/document_edit.py @@ -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//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, diff --git a/routes/document/document_element_add.py b/routes/document/document_element_add.py index 1b3c7a31..b51ddd84 100644 --- a/routes/document/document_element_add.py +++ b/routes/document/document_element_add.py @@ -10,25 +10,29 @@ from core.flask_app import app @app.route("/document//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( { diff --git a/routes/document/document_element_update.py b/routes/document/document_element_update.py index bfef0deb..3635811b 100644 --- a/routes/document/document_element_update.py +++ b/routes/document/document_element_update.py @@ -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( { diff --git a/routes/document/document_page_add.py b/routes/document/document_page_add.py new file mode 100644 index 00000000..86b60346 --- /dev/null +++ b/routes/document/document_page_add.py @@ -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//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//render?page_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}) diff --git a/routes/document/document_page_delete.py b/routes/document/document_page_delete.py new file mode 100644 index 00000000..f32ea4d1 --- /dev/null +++ b/routes/document/document_page_delete.py @@ -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//pages//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}) diff --git a/routes/document/document_page_move.py b/routes/document/document_page_move.py new file mode 100644 index 00000000..2767800e --- /dev/null +++ b/routes/document/document_page_move.py @@ -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//pages//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}) diff --git a/routes/document/document_page_rename.py b/routes/document/document_page_rename.py new file mode 100644 index 00000000..2b5f6151 --- /dev/null +++ b/routes/document/document_page_rename.py @@ -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//pages//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}) diff --git a/routes/document/document_render.py b/routes/document/document_render.py index 6a82627e..6ecc6994 100644 --- a/routes/document/document_render.py +++ b/routes/document/document_render.py @@ -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//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}) diff --git a/static/document/document-editor.css b/static/document/document-editor.css index 3deb9148..c246753f 100644 --- a/static/document/document-editor.css +++ b/static/document/document-editor.css @@ -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; } diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js index 4ced6558..27f54d05 100644 --- a/static/document/js/document-editor.js +++ b/static/document/js/document-editor.js @@ -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) => ` + + `).join(''); + container.innerHTML = `${tabsHtml}`; + + 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); diff --git a/templates/document/document_edit.html b/templates/document/document_edit.html index 56d8cb4f..445a0055 100644 --- a/templates/document/document_edit.html +++ b/templates/document/document_edit.html @@ -92,6 +92,18 @@ aria-label="Changer de thème"> +
+ {% for page in pages %} + + {% endfor %} + +
← Retour au document
@@ -135,6 +147,11 @@ 100%
+
+ + + +
← Retour au document
@@ -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 }} }; diff --git a/tests/document/test_document_elements.py b/tests/document/test_document_elements.py index 0ad7eb84..a5a3d319 100644 --- a/tests/document/test_document_elements.py +++ b/tests/document/test_document_elements.py @@ -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 diff --git a/tests/document/test_document_pages_routes.py b/tests/document/test_document_pages_routes.py new file mode 100644 index 00000000..1e88ca86 --- /dev/null +++ b/tests/document/test_document_pages_routes.py @@ -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 diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py index 53ea4471..d8424973 100644 --- a/tests/document/test_document_routes.py +++ b/tests/document/test_document_routes.py @@ -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",