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