From 864ae697fd8b7bd26daf3a58f9d89238defa2f9e Mon Sep 17 00:00:00 2001 From: william Date: Thu, 24 Sep 2026 09:12:57 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20le=20syst=C3=A8me=20de=20mod=C3=A8les/?= =?UTF-8?q?th=C3=A8mes=20de=20document=20("Utiliser=20un=20mod=C3=A8le")?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau bouton dans le topbar de l'éditeur, à côté d'Aperçu, qui ouvre une modale listant les thèmes du catalogue (document_engine/themes/). Cliquer un thème charge un VRAI aperçu (rendu serveur réel dans un iframe, jamais une resucée CSS côté client) avec le choix de garder le contenu actuel ou de le remplacer par le contenu de démonstration du modèle. Architecture pensée pour une centaine de thèmes futurs : chaque thème est une feuille de style externe (static/document/themes/.css) qui habille les classes fixes du moteur, jamais du code qui en changerait la structure. Premier thème implémenté pour valider le mécanisme : "Sécurité Incendie" (6 pages de contenu réel, quiz inclus). Co-Authored-By: Claude Sonnet 5 --- db/__init__.py | 4 + db/supports/__init__.py | 4 + db/supports/get_document_theme.py | 13 ++ db/supports/set_document_theme.py | 13 ++ db/supports/support_meta.py | 2 + document_engine/__init__.py | 7 + .../pages/replace_document_content.py | 48 +++++ document_engine/themes/__init__.py | 10 + .../themes/securite_incendie_seed.py | 178 ++++++++++++++++++ .../themes/seed_blocks_to_elements.py | 30 +++ document_engine/themes/theme_catalog.py | 32 ++++ routes/document/__init__.py | 2 + routes/document/document_edit.py | 16 ++ routes/document/document_theme_apply.py | 34 ++++ routes/document/document_theme_preview.py | 23 +++ static/document/document-editor.css | 119 ++++++++++++ static/document/js/document-editor.js | 102 ++++++++++ static/document/themes/securite-incendie.css | 169 +++++++++++++++++ templates/document/document_edit.html | 55 +++++- .../document/document_theme_preview.html | 29 +++ tests/document/test_document_elements.py | 46 +++++ tests/document/test_document_routes.py | 63 +++++++ tests/document/test_document_themes.py | 74 ++++++++ tests/document/test_support_lifecycle.py | 23 +++ vulture_whitelist.py | 12 ++ 25 files changed, 1107 insertions(+), 1 deletion(-) create mode 100644 db/supports/get_document_theme.py create mode 100644 db/supports/set_document_theme.py create mode 100644 document_engine/pages/replace_document_content.py create mode 100644 document_engine/themes/__init__.py create mode 100644 document_engine/themes/securite_incendie_seed.py create mode 100644 document_engine/themes/seed_blocks_to_elements.py create mode 100644 document_engine/themes/theme_catalog.py create mode 100644 routes/document/document_theme_apply.py create mode 100644 routes/document/document_theme_preview.py create mode 100644 static/document/themes/securite-incendie.css create mode 100644 templates/document/document_theme_preview.html create mode 100644 tests/document/test_document_themes.py diff --git a/db/__init__.py b/db/__init__.py index 5a4d31a6..909df00f 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -78,7 +78,9 @@ from .scoring.set_status import set_status from .slugify import slugify from .supports.create_support import create_support from .supports.delete_support import delete_support +from .supports.get_document_theme import get_document_theme from .supports.list_supports import list_supports +from .supports.set_document_theme import set_document_theme from .supports.support_dir import support_dir from .supports.support_meta import support_meta from .table_name_for import table_name_for @@ -119,6 +121,8 @@ __all__ = [ "support_dir", "support_meta", "delete_support", + "get_document_theme", + "set_document_theme", "ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type", diff --git a/db/supports/__init__.py b/db/supports/__init__.py index 281303da..be218a81 100644 --- a/db/supports/__init__.py +++ b/db/supports/__init__.py @@ -7,7 +7,9 @@ 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 .get_document_theme import get_document_theme from .list_supports import list_supports +from .set_document_theme import set_document_theme from .support_connection import connect_support, install_support_teardown_safety_net from .support_dir import support_dir from .support_meta import support_meta @@ -20,8 +22,10 @@ __all__ = [ "create_support", "delete_support", "ensure_document_pages_schema", + "get_document_theme", "install_support_teardown_safety_net", "list_supports", + "set_document_theme", "split_slug", "support_dir", "support_meta", diff --git a/db/supports/get_document_theme.py b/db/supports/get_document_theme.py new file mode 100644 index 00000000..04f8f049 --- /dev/null +++ b/db/supports/get_document_theme.py @@ -0,0 +1,13 @@ +from .support_connection import connect_support + + +def get_document_theme(slug: str) -> str | None: + """None si aucun thème n'a jamais été appliqué à ce support (état par + défaut : contenu non stylé, voir document_engine/rendering/) — jamais + une valeur par défaut arbitraire ici, `routes/document/document_edit.py` + décide seul quoi faire de ce None (ne charger aucune feuille de style + de thème).""" + conn = connect_support(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'theme'").fetchone() + conn.close() + return row["value"] if row else None diff --git a/db/supports/set_document_theme.py b/db/supports/set_document_theme.py new file mode 100644 index 00000000..71ebc0d8 --- /dev/null +++ b/db/supports/set_document_theme.py @@ -0,0 +1,13 @@ +from .support_connection import connect_support + + +def set_document_theme(slug: str, theme_id: str) -> None: + """Mirroir de db/games/game_type_catalog.py::set_onboarding_type + (même pattern INSERT OR REPLACE sur _meta) — `theme_id` n'est PAS + revalidé contre le catalogue ici (couche données pure) : c'est + routes/document/document_theme_apply.py, seul appelant, qui vérifie + que le thème existe avant d'appeler cette fonction.""" + conn = connect_support(slug) + conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('theme', ?)", (theme_id,)) + conn.commit() + conn.close() diff --git a/db/supports/support_meta.py b/db/supports/support_meta.py index 4c6bd0db..96e6e67d 100644 --- a/db/supports/support_meta.py +++ b/db/supports/support_meta.py @@ -1,5 +1,6 @@ from typing import Any +from .get_document_theme import get_document_theme from .support_connection import connect_support @@ -10,4 +11,5 @@ def support_meta(slug: str) -> dict[str, Any]: return { "slug": slug, "name": row["value"] if row else slug, + "theme": get_document_theme(slug), } diff --git a/document_engine/__init__.py b/document_engine/__init__.py index 59dc1b74..e3608a88 100644 --- a/document_engine/__init__.py +++ b/document_engine/__init__.py @@ -75,9 +75,12 @@ 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.replace_document_content import replace_document_content from .pages.update_document_page import update_document_page from .rendering.render_document_element import render_document, render_document_element from .rendering.sanitize_svg_markup import sanitize_svg_markup +from .themes.seed_blocks_to_elements import seed_blocks_to_elements +from .themes.theme_catalog import DOCUMENT_THEMES, get_document_theme_entry __all__ = [ "CARD_MODES", @@ -87,6 +90,7 @@ __all__ = [ "DEFAULT_MOTS_CONFIG", "DEFAULT_QUIZ_CONFIG", "DEFAULT_SCENARIO_CONFIG", + "DOCUMENT_THEMES", "ELEMENT_KIND_LABELS", "ELEMENT_LIBRARY", "MAX_CARDS", @@ -108,6 +112,7 @@ __all__ = [ "element_default_attributes", "get_document_element", "get_document_page", + "get_document_theme_entry", "list_document_elements", "list_document_pages", "move_document_element", @@ -116,6 +121,7 @@ __all__ = [ "quiz_total_points", "render_document", "render_document_element", + "replace_document_content", "sanitize_association_config", "sanitize_element_attributes", "sanitize_memory_config", @@ -123,6 +129,7 @@ __all__ = [ "sanitize_quiz_config", "sanitize_scenario_config", "sanitize_svg_markup", + "seed_blocks_to_elements", "update_document_element_attributes", "update_document_page", ] diff --git a/document_engine/pages/replace_document_content.py b/document_engine/pages/replace_document_content.py new file mode 100644 index 00000000..0637e541 --- /dev/null +++ b/document_engine/pages/replace_document_content.py @@ -0,0 +1,48 @@ +from typing import Any + +from ..elements.add_document_element import add_document_element +from ..elements.update_document_element_attributes import update_document_element_attributes +from ..labels.element_kind_labels import element_default_attributes +from .add_document_page import add_document_page +from .delete_document_page import delete_document_page +from .list_document_pages import list_document_pages + + +def replace_document_content(slug: str, seed_pages: list[list[dict[str, Any]]]) -> None: + """Remplace TOUT le contenu du support par `seed_pages` — utilisé + UNIQUEMENT quand le créateur choisit explicitement "utiliser le + contenu du modèle" en appliquant un thème (voir routes/document/ + document_theme_apply.py, jamais appelée sans confirmation explicite + côté client : action destructive, irréversible côté serveur). + + `seed_pages` est une liste de pages, chaque page une liste de blocs + `{"kind": str, "attributes": dict, "children": [...]}` (`children` + optionnel, uniquement pour un bloc `kind="row"` — chaque enfant a la + même forme `{"kind", "attributes"}`, sans petit-enfant : le moteur de + rangées ne descend jamais à plus d'un niveau, voir document_engine/ + rendering/render_document_element.py::_render_row). Les attributs + fournis sont FUSIONNÉS sur element_default_attributes(kind) (jamais un + remplacement brut) pour rester valides même si `seed_pages` n'en + précise qu'une partie.""" + # Les nouvelles pages sont créées AVANT de supprimer les anciennes + # (jamais l'inverse) : passer par zéro page, même un court instant, + # déclenche le filet de sécurité de ensure_document_pages_schema (un + # support a TOUJOURS au moins une page), qui recréerait une "Page 1" + # vide juste avant l'ajout de la première page du thème — bug réel + # rencontré en écrivant les tests de cette fonction. + old_page_ids = [page["id"] for page in list_document_pages(slug)] + for page_blocks in seed_pages: + page_id = add_document_page(slug) + for block in page_blocks: + _add_seed_block(slug, page_id, block, parent_id=None) + for old_page_id in old_page_ids: + delete_document_page(slug, old_page_id) + + +def _add_seed_block(slug: str, page_id: int, block: dict[str, Any], parent_id: int | None) -> None: + kind = block["kind"] + element_id = add_document_element(slug, kind, page_id=page_id, parent_id=parent_id) + attributes = {**element_default_attributes(kind), **block.get("attributes", {})} + update_document_element_attributes(slug, element_id, attributes) + for child in block.get("children", []): + _add_seed_block(slug, page_id, child, parent_id=element_id) diff --git a/document_engine/themes/__init__.py b/document_engine/themes/__init__.py new file mode 100644 index 00000000..d30557a9 --- /dev/null +++ b/document_engine/themes/__init__.py @@ -0,0 +1,10 @@ +from .securite_incendie_seed import SECURITE_INCENDIE_SEED_PAGES +from .seed_blocks_to_elements import seed_blocks_to_elements +from .theme_catalog import DOCUMENT_THEMES, get_document_theme_entry + +__all__ = [ + "DOCUMENT_THEMES", + "SECURITE_INCENDIE_SEED_PAGES", + "get_document_theme_entry", + "seed_blocks_to_elements", +] diff --git a/document_engine/themes/securite_incendie_seed.py b/document_engine/themes/securite_incendie_seed.py new file mode 100644 index 00000000..583054a7 --- /dev/null +++ b/document_engine/themes/securite_incendie_seed.py @@ -0,0 +1,178 @@ +"""Contenu de démonstration du thème "Sécurité Incendie" (voir +theme_catalog.py) — vraie curriculum de formation, jamais du texte de +remplissage (voir CLAUDE.md, artifact-design : "Build with real content +throughout, never lorem"). Un support qui applique ce thème en mode +"utiliser le contenu du modèle" (voir routes/document/ +document_theme_apply.py) reçoit EXACTEMENT ces pages, entièrement +modifiables ensuite comme n'importe quel contenu créé à la main.""" + +from typing import Any + +SECURITE_INCENDIE_SEED_PAGES: list[list[dict[str, Any]]] = [ + # Page 1 — Titre + [ + {"kind": "badge", "attributes": {"content": "Module obligatoire"}}, + {"kind": "titre", "attributes": {"content": "Formation Sécurité Incendie", "style": "titre1"}}, + { + "kind": "paragraphe", + "attributes": { + "content": ( + "Reconnaître les risques, réagir dans les 3 premières minutes, protéger les " + "personnes autour de vous. Durée : 25 min · à renouveler tous les 24 mois." + ) + }, + }, + ], + # Page 2 — Objectifs + [ + {"kind": "titre", "attributes": {"content": "À l'issue de ce module, vous saurez", "style": "titre2"}}, + { + "kind": "liste_puces", + "attributes": { + "items": [ + "Identifier les causes principales d'un départ de feu sur un poste de travail", + "Choisir le bon type d'extincteur selon la classe de feu rencontrée", + "Déclencher l'alarme et évacuer en moins de 3 minutes", + ] + }, + }, + { + "kind": "badge", + "attributes": { + "content": ( + "Un feu peut doubler de volume toutes les 30 secondes — la rapidité de " + "réaction compte autant que le geste." + ) + }, + }, + ], + # Page 3 — Classes de feu + [ + {"kind": "titre", "attributes": {"content": "Les 5 classes de feu", "style": "titre2"}}, + { + "kind": "paragraphe", + "attributes": { + "content": ( + "Chaque classe correspond à un combustible différent — le mauvais " + "extincteur peut aggraver l'incendie." + ) + }, + }, + { + "kind": "row", + "attributes": {"gap": 10, "align": "stretch", "justify": "flex-start"}, + "children": [ + { + "kind": "carte", + "attributes": {"label": "A", "title": "Feux secs", "description": "Bois, papier, tissus"}, + }, + { + "kind": "carte", + "attributes": {"label": "B", "title": "Liquides", "description": "Essence, solvants"}, + }, + {"kind": "carte", "attributes": {"label": "C", "title": "Gaz", "description": "Butane, propane"}}, + {"kind": "carte", "attributes": {"label": "D", "title": "Métaux", "description": "Sodium, magnésium"}}, + { + "kind": "carte", + "attributes": {"label": "F", "title": "Huiles", "description": "Friteuses, graisses"}, + }, + ], + }, + { + "kind": "badge", + "attributes": { + "content": ( + "⚠ Un feu électrique n'est pas une classe à part : coupez toujours le courant avant d'intervenir." + ) + }, + }, + ], + # Page 4 — Méthode P.A.S.S. + [ + {"kind": "titre", "attributes": {"content": "Utiliser un extincteur : la méthode P.A.S.S.", "style": "titre2"}}, + { + "kind": "liste_numerotee", + "attributes": { + "items": [ + "Percuter — retirez la goupille de sécurité", + "Approcher — à 2 ou 3 mètres du foyer", + "Serrer — pressions courtes, pas en continu", + "Balayer — la base des flammes, gauche à droite", + ] + }, + }, + { + "kind": "row", + "attributes": {"gap": 12, "align": "center", "justify": "space-between"}, + "children": [ + { + "kind": "badge", + "attributes": {"content": "⚠ Jamais d'eau sur un feu électrique ou une friteuse en feu."}, + }, + {"kind": "bouton", "attributes": {"label": "Fiche consignes", "target": ""}}, + ], + }, + ], + # Page 5 — Évacuation + [ + {"kind": "titre", "attributes": {"content": "Consignes d'évacuation", "style": "titre2"}}, + { + "kind": "paragraphe", + "attributes": {"content": "Dès que l'alarme retentit, l'évacuation est immédiate — aucune exception."}, + }, + { + "kind": "liste_numerotee", + "attributes": { + "items": [ + "Cessez toute activité, laissez vos affaires sur place", + "Suivez le fléchage vers la sortie la plus proche, jamais l'ascenseur", + "Rejoignez le point de rassemblement, attendez l'appel de votre nom", + "Ne retournez jamais à l'intérieur avant le signal du responsable", + ] + }, + }, + ], + # Page 6 — Quiz (SEUL sur sa page : règle du moteur, voir + # routes/document/document_element_add.py — respectée ici dès la + # conception du contenu-seed, jamais contournée). + [ + { + "kind": "quiz", + "attributes": { + "theme_color": "#c8102e", + "timer_enabled": False, + "timer_seconds": 30, + "questions": [ + { + "text": ( + "Quelle classe de feu concerne les liquides inflammables comme l'essence ou les solvants ?" + ), + "choices": ["Classe A", "Classe B", "Classe D"], + "correct_index": 1, + "points": 10, + }, + { + "text": "Quel est le premier geste de la méthode P.A.S.S. ?", + "choices": [ + "Balayer la base des flammes", + "Percuter (retirer la goupille)", + "Approcher à 1 mètre", + ], + "correct_index": 1, + "points": 10, + }, + { + "text": "Que faire dès que l'alarme incendie retentit ?", + "choices": [ + "Terminer sa tâche puis sortir", + "Prendre l'ascenseur pour aller plus vite", + "Évacuer immédiatement par les issues de secours", + ], + "correct_index": 2, + "points": 10, + }, + ], + }, + } + ], +] diff --git a/document_engine/themes/seed_blocks_to_elements.py b/document_engine/themes/seed_blocks_to_elements.py new file mode 100644 index 00000000..7944664e --- /dev/null +++ b/document_engine/themes/seed_blocks_to_elements.py @@ -0,0 +1,30 @@ +from typing import Any + +from ..labels.element_kind_labels import element_default_attributes + + +def seed_blocks_to_elements(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Convertit une liste de blocs de contenu-seed (voir theme_catalog.py + — `seed_pages`) en une liste d'éléments "à plat" directement + exploitable par `render_document_element.render_document` — ids + synthétiques négatifs, JAMAIS persistés (aperçu d'un thème + uniquement, voir routes/document/document_theme_preview.py ; pour la + persistance réelle voir document_engine.replace_document_content, + qui ne réutilise pas cette fonction — elle a besoin de vrais ids + attribués par la base au fil des insertions).""" + elements: list[dict[str, Any]] = [] + next_id = -1 + for block in blocks: + next_id = _add_block(elements, block, parent_id=None, next_id=next_id) + return elements + + +def _add_block(elements: list[dict[str, Any]], block: dict[str, Any], parent_id: int | None, next_id: int) -> int: + element_id = next_id + next_id -= 1 + kind = block["kind"] + attributes = {**element_default_attributes(kind), **block.get("attributes", {})} + elements.append({"id": element_id, "kind": kind, "parent_id": parent_id, "attributes": attributes}) + for child in block.get("children", []): + next_id = _add_block(elements, child, parent_id=element_id, next_id=next_id) + return next_id diff --git a/document_engine/themes/theme_catalog.py b/document_engine/themes/theme_catalog.py new file mode 100644 index 00000000..f915db6b --- /dev/null +++ b/document_engine/themes/theme_catalog.py @@ -0,0 +1,32 @@ +"""Catalogue des thèmes visuels applicables à un support (voir consigne +du 24/09/2026 : le moteur ne porte QUE contenu et mécanisme — chaque +thème est une feuille de style externe (`css_path`, servie telle quelle +depuis static/) qui habille les mêmes classes fixes du moteur +(.docPage/.docText/.docList/.docCard/.docBadge/.docMinigame/...), jamais +du code Python qui en changerait la structure.""" + +from typing import Any + +from .securite_incendie_seed import SECURITE_INCENDIE_SEED_PAGES + +DOCUMENT_THEMES: list[dict[str, Any]] = [ + { + "id": "securite-incendie", + "name": "Sécurité Incendie", + "category": "Prévention & sécurité", + "description": "Rouge sécurité et ambre balisage, typographie signalétique — pour une formation réglementaire.", + "css_path": "document/themes/securite-incendie.css", + "font_url": ( + "https://fonts.googleapis.com/css2?" + "family=Oswald:wght@500;600;700&family=Source+Sans+3:wght@400;500;600;700&display=swap" + ), + "seed_pages": SECURITE_INCENDIE_SEED_PAGES, + }, +] + + +def get_document_theme_entry(theme_id: str) -> dict[str, Any] | None: + """- **Retour** : l'entrée du catalogue dont `id == theme_id`, ou + `None` si aucun thème de ce catalogue ne porte cet id. + - **Exceptions** : aucune.""" + return next((theme for theme in DOCUMENT_THEMES if theme["id"] == theme_id), None) diff --git a/routes/document/__init__.py b/routes/document/__init__.py index 05b89d31..0d3585e7 100644 --- a/routes/document/__init__.py +++ b/routes/document/__init__.py @@ -19,4 +19,6 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul document_page_move, document_page_rename, document_render, + document_theme_apply, + document_theme_preview, ) diff --git a/routes/document/document_edit.py b/routes/document/document_edit.py index b90d1cf9..16baa744 100644 --- a/routes/document/document_edit.py +++ b/routes/document/document_edit.py @@ -44,6 +44,8 @@ def document_edit(slug: str) -> str: for el in document_engine.list_document_elements(slug, active_page["id"]) ] + active_theme = document_engine.get_document_theme_entry(support["theme"]) if support["theme"] else None + return render_template( "document/document_edit.html", support=support, @@ -53,4 +55,18 @@ def document_edit(slug: str) -> str: rendered_document=document_engine.render_document(active_elements), element_library=document_engine.ELEMENT_LIBRARY, element_kind_labels=document_engine.ELEMENT_KIND_LABELS, + active_theme=active_theme, + # Seuls les champs utiles à la modale "Utiliser un modèle" côté + # client (voir static/document/js/document-editor.js) — jamais le + # `seed_pages` complet, inutilement volumineux et non nécessaire + # côté client (l'aperçu et l'application se font tous deux en + # appelant le serveur, voir document_theme_preview.py/ + # document_theme_apply.py). Pas de route JSON dédiée pour une + # donnée 100% statique côté serveur : le catalogue tient déjà + # dans le contexte de cette page (voir document_engine/themes/ + # theme_catalog.py). + document_themes=[ + {"id": t["id"], "name": t["name"], "category": t["category"], "description": t["description"]} + for t in document_engine.DOCUMENT_THEMES + ], ) diff --git a/routes/document/document_theme_apply.py b/routes/document/document_theme_apply.py new file mode 100644 index 00000000..c4b4b78c --- /dev/null +++ b/routes/document/document_theme_apply.py @@ -0,0 +1,34 @@ +from flask import jsonify, request +from werkzeug.wrappers import Response + +import db +import document_engine +from core.flask_app import app + + +@app.route("/document//theme/apply", methods=["POST"]) +def document_theme_apply(slug: str) -> Response | tuple[Response, int]: + """Applique un thème visuel au support — voir document_engine/themes/. + `mode` décide du sort du contenu ACTUEL : + - "keep_content" (défaut) : seul le thème change, le contenu du + support reste tel quel. + - "template_content" : le contenu du support est intégralement + remplacé par le contenu de démonstration du thème (voir + document_engine.replace_document_content) — action destructive, + dont la confirmation est à la charge du client (voir + static/document/js/document-editor.js, aucune confirmation ici + côté serveur : ce n'est pas son rôle).""" + payload = request.get_json(force=True) or {} + theme_id = str(payload.get("theme_id", "")) + theme = document_engine.get_document_theme_entry(theme_id) + if theme is None: + return jsonify({"error": "thème introuvable"}), 404 + mode = payload.get("mode", "keep_content") + if mode not in ("keep_content", "template_content"): + return jsonify({"error": "mode invalide"}), 400 + + db.set_document_theme(slug, theme_id) + if mode == "template_content": + document_engine.replace_document_content(slug, theme["seed_pages"]) + + return jsonify({"ok": True, "theme_id": theme_id}) diff --git a/routes/document/document_theme_preview.py b/routes/document/document_theme_preview.py new file mode 100644 index 00000000..63c090ac --- /dev/null +++ b/routes/document/document_theme_preview.py @@ -0,0 +1,23 @@ +from flask import render_template +from werkzeug.exceptions import NotFound + +import document_engine +from core.flask_app import app + + +@app.route("/document//theme//preview") +def document_theme_preview(slug: str, theme_id: str) -> str: + """Aperçu isolé (destiné à un +
Sélectionnez un modèle pour le visualiser.
+ + + + + diff --git a/templates/document/document_theme_preview.html b/templates/document/document_theme_preview.html new file mode 100644 index 00000000..9be0b7a0 --- /dev/null +++ b/templates/document/document_theme_preview.html @@ -0,0 +1,29 @@ + + + + + Aperçu — {{ theme.name }} + + + {% if theme.font_url %} + + {% endif %} + + + + +
+
+ {{ rendered_page | safe }} +
+
+ + diff --git a/tests/document/test_document_elements.py b/tests/document/test_document_elements.py index e840294f..368fcb9a 100644 --- a/tests/document/test_document_elements.py +++ b/tests/document/test_document_elements.py @@ -331,6 +331,52 @@ def test_bouton_default_attributes_include_empty_attachment_fields(tmp_support_s } +def test_replace_document_content_rebuilds_pages_and_elements_from_seed(tmp_support_slug_cleanup: Any) -> None: + slug, page_id = _new_support(tmp_support_slug_cleanup) + doc_engine.add_document_element(slug, "titre", page_id=page_id) + + seed_pages: list[list[dict[str, Any]]] = [ + [ + {"kind": "titre", "attributes": {"content": "Page 1 du modèle"}}, + { + "kind": "row", + "attributes": {"gap": 20}, + "children": [ + {"kind": "paragraphe", "attributes": {"content": "Colonne gauche"}}, + {"kind": "paragraphe", "attributes": {"content": "Colonne droite"}}, + ], + }, + ], + [{"kind": "liste_puces", "attributes": {"items": ["Un", "Deux"]}}], + ] + doc_engine.replace_document_content(slug, seed_pages) + + pages = doc_engine.list_document_pages(slug) + assert len(pages) == 2 + + page1_elements = doc_engine.list_document_elements(slug, pages[0]["id"]) + titre = next(el for el in page1_elements if el["kind"] == "titre") + assert titre["attributes"]["content"] == "Page 1 du modèle" + row = next(el for el in page1_elements if el["kind"] == "row") + children = [el for el in page1_elements if el["parent_id"] == row["id"]] + assert len(children) == 2 + assert {c["attributes"]["content"] for c in children} == {"Colonne gauche", "Colonne droite"} + + page2_elements = doc_engine.list_document_elements(slug, pages[1]["id"]) + assert page2_elements[0]["kind"] == "liste_puces" + assert page2_elements[0]["attributes"]["items"] == ["Un", "Deux"] + + +def test_replace_document_content_merges_attributes_onto_defaults(tmp_support_slug_cleanup: Any) -> None: + slug, _page_id = _new_support(tmp_support_slug_cleanup) + doc_engine.replace_document_content(slug, [[{"kind": "titre", "attributes": {"content": "Seulement le contenu"}}]]) + page_id = doc_engine.list_document_pages(slug)[0]["id"] + element = doc_engine.list_document_elements(slug, page_id)[0] + assert element["attributes"]["content"] == "Seulement le contenu" + assert element["attributes"]["style"] == "titre1" + assert element["attributes"]["align"] == "left" + + def test_bouton_with_attachment_filename_renders_data_attribute(tmp_support_slug_cleanup: Any) -> None: slug, page_id = _new_support(tmp_support_slug_cleanup) element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id) diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py index 487e0056..a2838a63 100644 --- a/tests/document/test_document_routes.py +++ b/tests/document/test_document_routes.py @@ -400,3 +400,66 @@ def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient, element_id = _add_bouton(client, support) resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment") assert resp.status_code == 404 + + +def test_document_edit_has_no_theme_link_by_default(client: FlaskClient, support: str) -> None: + resp = client.get(f"/document/{support}/edit") + assert "document/themes/securite-incendie.css" not in resp.get_data(as_text=True) + + +def test_document_theme_apply_rejects_an_unknown_theme(client: FlaskClient, support: str) -> None: + resp = client.post( + f"/document/{support}/theme/apply", + json={"theme_id": "n-importe-quoi", "mode": "keep_content"}, + ) + assert resp.status_code == 404 + + +def test_document_theme_apply_rejects_an_invalid_mode(client: FlaskClient, support: str) -> None: + resp = client.post( + f"/document/{support}/theme/apply", + json={"theme_id": "securite-incendie", "mode": "n-importe-quoi"}, + ) + assert resp.status_code == 400 + + +def test_document_theme_apply_keep_content_only_sets_the_theme(client: FlaskClient, support: str) -> None: + page_id = _page_id(support) + client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}) + + resp = client.post( + f"/document/{support}/theme/apply", + json={"theme_id": "securite-incendie", "mode": "keep_content"}, + ) + assert resp.status_code == 200 + assert db.get_document_theme(support) == "securite-incendie" + assert len(document_engine.list_document_elements(support, page_id)) == 1 + + edit_resp = client.get(f"/document/{support}/edit") + assert "document/themes/securite-incendie.css" in edit_resp.get_data(as_text=True) + + +def test_document_theme_apply_template_content_replaces_everything(client: FlaskClient, support: str) -> None: + page_id = _page_id(support) + client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}) + + resp = client.post( + f"/document/{support}/theme/apply", + json={"theme_id": "securite-incendie", "mode": "template_content"}, + ) + assert resp.status_code == 200 + pages = document_engine.list_document_pages(support) + theme = document_engine.get_document_theme_entry("securite-incendie") + assert theme is not None + assert len(pages) == len(theme["seed_pages"]) + + +def test_document_theme_preview_renders_the_first_seed_page(client: FlaskClient, support: str) -> None: + resp = client.get(f"/document/{support}/theme/securite-incendie/preview") + assert resp.status_code == 200 + assert "docPage" in resp.get_data(as_text=True) + + +def test_document_theme_preview_404s_for_an_unknown_theme(client: FlaskClient, support: str) -> None: + resp = client.get(f"/document/{support}/theme/n-importe-quoi/preview") + assert resp.status_code == 404 diff --git a/tests/document/test_document_themes.py b/tests/document/test_document_themes.py new file mode 100644 index 00000000..53345b06 --- /dev/null +++ b/tests/document/test_document_themes.py @@ -0,0 +1,74 @@ +"""Catalogue de thèmes visuels (document_engine/themes/) — conversion du +contenu-seed en éléments "à plat" pour l'aperçu (seed_blocks_to_elements), +et respect par CHAQUE thème du catalogue des règles structurelles du +moteur (ex. un mini-jeu seul sur sa page, voir routes/document/ +document_element_add.py) — vérifié ici statiquement sur les données du +catalogue, sans passer par une route HTTP.""" + +from typing import Any + +import document_engine as doc_engine + + +def test_get_document_theme_entry_returns_none_for_an_unknown_id() -> None: + assert doc_engine.get_document_theme_entry("n-importe-quoi") is None + + +def test_get_document_theme_entry_returns_the_matching_entry() -> None: + entry = doc_engine.get_document_theme_entry("securite-incendie") + assert entry is not None + assert entry["id"] == "securite-incendie" + assert entry["name"] == "Sécurité Incendie" + assert len(entry["seed_pages"]) > 0 + + +def test_seed_blocks_to_elements_assigns_unique_synthetic_ids() -> None: + blocks: list[dict[str, Any]] = [ + {"kind": "titre", "attributes": {"content": "Titre"}}, + { + "kind": "row", + "attributes": {"gap": 10}, + "children": [ + {"kind": "paragraphe", "attributes": {"content": "A"}}, + {"kind": "paragraphe", "attributes": {"content": "B"}}, + ], + }, + ] + elements = doc_engine.seed_blocks_to_elements(blocks) + ids = [el["id"] for el in elements] + assert len(ids) == len(set(ids)) + assert len(elements) == 4 + + row = next(el for el in elements if el["kind"] == "row") + children = [el for el in elements if el["parent_id"] == row["id"]] + assert len(children) == 2 + + +def test_seed_blocks_to_elements_merges_onto_default_attributes() -> None: + elements = doc_engine.seed_blocks_to_elements([{"kind": "titre", "attributes": {"content": "Contenu seul"}}]) + assert elements[0]["attributes"]["content"] == "Contenu seul" + assert elements[0]["attributes"]["style"] == "titre1" + + +def test_seed_blocks_to_elements_renders_without_error() -> None: + theme = doc_engine.get_document_theme_entry("securite-incendie") + assert theme is not None + elements = doc_engine.seed_blocks_to_elements(theme["seed_pages"][0]) + html = doc_engine.render_document(elements) + assert "docText" in html + + +def test_every_theme_seed_page_respects_the_minigame_exclusivity_rule() -> None: + """Un mini-jeu doit toujours être SEUL sur sa page (règle appliquée + côté route pour un ajout manuel, voir routes/document/ + document_element_add.py) — le contenu-seed d'un thème la respecte + dès sa conception puisque document_engine.replace_document_content + ne la revérifie pas elle-même (contenu fourni par le thème, pas par + l'utilisateur).""" + for theme in doc_engine.DOCUMENT_THEMES: + for page_blocks in theme["seed_pages"]: + minigame_blocks = [b for b in page_blocks if b["kind"] in doc_engine.MINIGAME_KINDS] + if minigame_blocks: + assert len(page_blocks) == 1, ( + f"thème {theme['id']!r} : une page avec mini-jeu doit contenir uniquement ce mini-jeu" + ) diff --git a/tests/document/test_support_lifecycle.py b/tests/document/test_support_lifecycle.py index 0cbc6664..79489f01 100644 --- a/tests/document/test_support_lifecycle.py +++ b/tests/document/test_support_lifecycle.py @@ -59,3 +59,26 @@ def test_delete_support_removes_it_from_the_listing(tmp_support_slug_cleanup: An assert [s["slug"] for s in db.list_supports("48")] == [slug] db.delete_support(slug) assert db.list_supports("48") == [] + + +def test_new_support_has_no_theme_by_default(tmp_support_slug_cleanup: Any) -> None: + slug = db.create_support("Sans thème", owner_folder="49") + tmp_support_slug_cleanup(slug) + assert db.get_document_theme(slug) is None + assert db.support_meta(slug)["theme"] is None + + +def test_set_document_theme_persists_and_is_readable_back(tmp_support_slug_cleanup: Any) -> None: + slug = db.create_support("Avec thème", owner_folder="50") + tmp_support_slug_cleanup(slug) + db.set_document_theme(slug, "securite-incendie") + assert db.get_document_theme(slug) == "securite-incendie" + assert db.support_meta(slug)["theme"] == "securite-incendie" + + +def test_set_document_theme_can_be_changed(tmp_support_slug_cleanup: Any) -> None: + slug = db.create_support("Change de thème", owner_folder="51") + tmp_support_slug_cleanup(slug) + db.set_document_theme(slug, "securite-incendie") + db.set_document_theme(slug, "autre-theme") + assert db.get_document_theme(slug) == "autre-theme" diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 1b60abb5..393e35f6 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -26,3 +26,15 @@ from core import ( ) _ = (auth_guard, csrf, csrf_guard, db_teardown_guard, jinja_filters, recovery_codes_flash) + +# routes/document/document_theme_preview.py::document_theme_preview(slug, theme_id) — +# `slug` doit rester dans la signature (Flask appelle la vue avec un +# kwarg par segment / de la route, TypeError sinon), mais +# le corps de la fonction ne s'en sert pas : l'aperçu d'un thème ne +# dépend d'aucune donnée DU support, `slug` ne sert qu'à laisser +# core/auth_guard.py (générique sur `request.view_args.get("slug")`) +# vérifier la propriété avant d'atteindre la vue. + + +def _unused_but_required_route_param(slug: str) -> None: + _ = slug