Ajoute le système de modèles/thèmes de document ("Utiliser un modèle")

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/<id>.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 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-24 09:12:57 +02:00
co-authored by Claude Sonnet 5
parent a34bcf4159
commit 864ae697fd
25 changed files with 1107 additions and 1 deletions
+4
View File
@@ -78,7 +78,9 @@ from .scoring.set_status import set_status
from .slugify import slugify from .slugify import slugify
from .supports.create_support import create_support from .supports.create_support import create_support
from .supports.delete_support import delete_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.list_supports import list_supports
from .supports.set_document_theme import set_document_theme
from .supports.support_dir import support_dir from .supports.support_dir import support_dir
from .supports.support_meta import support_meta from .supports.support_meta import support_meta
from .table_name_for import table_name_for from .table_name_for import table_name_for
@@ -119,6 +121,8 @@ __all__ = [
"support_dir", "support_dir",
"support_meta", "support_meta",
"delete_support", "delete_support",
"get_document_theme",
"set_document_theme",
"ONBOARDING_TYPES", "ONBOARDING_TYPES",
"DEFAULT_ONBOARDING_TYPE", "DEFAULT_ONBOARDING_TYPE",
"get_onboarding_type", "get_onboarding_type",
+4
View File
@@ -7,7 +7,9 @@ indépendance."""
from .create_support import create_support from .create_support import create_support
from .delete_support import delete_support from .delete_support import delete_support
from .ensure_document_pages_schema import ensure_document_pages_schema 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 .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_connection import connect_support, install_support_teardown_safety_net
from .support_dir import support_dir from .support_dir import support_dir
from .support_meta import support_meta from .support_meta import support_meta
@@ -20,8 +22,10 @@ __all__ = [
"create_support", "create_support",
"delete_support", "delete_support",
"ensure_document_pages_schema", "ensure_document_pages_schema",
"get_document_theme",
"install_support_teardown_safety_net", "install_support_teardown_safety_net",
"list_supports", "list_supports",
"set_document_theme",
"split_slug", "split_slug",
"support_dir", "support_dir",
"support_meta", "support_meta",
+13
View File
@@ -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
+13
View File
@@ -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()
+2
View File
@@ -1,5 +1,6 @@
from typing import Any from typing import Any
from .get_document_theme import get_document_theme
from .support_connection import connect_support from .support_connection import connect_support
@@ -10,4 +11,5 @@ def support_meta(slug: str) -> dict[str, Any]:
return { return {
"slug": slug, "slug": slug,
"name": row["value"] if row else slug, "name": row["value"] if row else slug,
"theme": get_document_theme(slug),
} }
+7
View File
@@ -75,9 +75,12 @@ from .pages.delete_document_page import delete_document_page
from .pages.get_document_page import get_document_page from .pages.get_document_page import get_document_page
from .pages.list_document_pages import list_document_pages from .pages.list_document_pages import list_document_pages
from .pages.move_document_page import move_document_page 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 .pages.update_document_page import update_document_page
from .rendering.render_document_element import render_document, render_document_element from .rendering.render_document_element import render_document, render_document_element
from .rendering.sanitize_svg_markup import sanitize_svg_markup 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__ = [ __all__ = [
"CARD_MODES", "CARD_MODES",
@@ -87,6 +90,7 @@ __all__ = [
"DEFAULT_MOTS_CONFIG", "DEFAULT_MOTS_CONFIG",
"DEFAULT_QUIZ_CONFIG", "DEFAULT_QUIZ_CONFIG",
"DEFAULT_SCENARIO_CONFIG", "DEFAULT_SCENARIO_CONFIG",
"DOCUMENT_THEMES",
"ELEMENT_KIND_LABELS", "ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY", "ELEMENT_LIBRARY",
"MAX_CARDS", "MAX_CARDS",
@@ -108,6 +112,7 @@ __all__ = [
"element_default_attributes", "element_default_attributes",
"get_document_element", "get_document_element",
"get_document_page", "get_document_page",
"get_document_theme_entry",
"list_document_elements", "list_document_elements",
"list_document_pages", "list_document_pages",
"move_document_element", "move_document_element",
@@ -116,6 +121,7 @@ __all__ = [
"quiz_total_points", "quiz_total_points",
"render_document", "render_document",
"render_document_element", "render_document_element",
"replace_document_content",
"sanitize_association_config", "sanitize_association_config",
"sanitize_element_attributes", "sanitize_element_attributes",
"sanitize_memory_config", "sanitize_memory_config",
@@ -123,6 +129,7 @@ __all__ = [
"sanitize_quiz_config", "sanitize_quiz_config",
"sanitize_scenario_config", "sanitize_scenario_config",
"sanitize_svg_markup", "sanitize_svg_markup",
"seed_blocks_to_elements",
"update_document_element_attributes", "update_document_element_attributes",
"update_document_page", "update_document_page",
] ]
@@ -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)
+10
View File
@@ -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",
]
@@ -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,
},
],
},
}
],
]
@@ -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
+32
View File
@@ -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)
+2
View File
@@ -19,4 +19,6 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
document_page_move, document_page_move,
document_page_rename, document_page_rename,
document_render, document_render,
document_theme_apply,
document_theme_preview,
) )
+16
View File
@@ -44,6 +44,8 @@ def document_edit(slug: str) -> str:
for el in document_engine.list_document_elements(slug, active_page["id"]) 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( return render_template(
"document/document_edit.html", "document/document_edit.html",
support=support, support=support,
@@ -53,4 +55,18 @@ def document_edit(slug: str) -> str:
rendered_document=document_engine.render_document(active_elements), rendered_document=document_engine.render_document(active_elements),
element_library=document_engine.ELEMENT_LIBRARY, element_library=document_engine.ELEMENT_LIBRARY,
element_kind_labels=document_engine.ELEMENT_KIND_LABELS, 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
],
) )
+34
View File
@@ -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/<slug>/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})
+23
View File
@@ -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/<slug>/theme/<theme_id>/preview")
def document_theme_preview(slug: str, theme_id: str) -> str:
"""Aperçu isolé (destiné à un <iframe>, voir la modale "Utiliser un
modèle" dans static/document/js/document-editor.js) de la première
page de démonstration d'un thème — RÉELLEMENT rendue par le moteur
(document_engine.render_document), jamais une image statique ou une
resucée manuelle du CSS : ce qui est prévisualisé est EXACTEMENT ce
qui s'affichera une fois le thème appliqué. `slug` sert uniquement à
la garde de propriété (core/auth_guard.py, générique sur toute route
<slug>) — l'aperçu ne dépend d'aucune donnée de CE support."""
theme = document_engine.get_document_theme_entry(theme_id)
if theme is None:
raise NotFound
elements = document_engine.seed_blocks_to_elements(theme["seed_pages"][0])
rendered_page = document_engine.render_document(elements)
return render_template("document/document_theme_preview.html", theme=theme, rendered_page=rendered_page)
+119
View File
@@ -2276,3 +2276,122 @@ img.docImage {
flex-direction: column; flex-direction: column;
} }
} }
/* ---- Modale "Utiliser un modèle" (voir static/document/js/
document-editor.js, forgeDocOpenTemplateModal) : liste des thèmes du
catalogue à gauche, aperçu RÉEL du thème sélectionné (chargé dans un
<iframe> pointant sur /document/<slug>/theme/<id>/preview, jamais un
style recopié à la main) à droite. Plus large que la modale générique
par défaut (comme #docScenarioTreeModal ci-dessus), pour laisser de
la place à l'aperçu. ---- */
.docModalDialog--wide {
width: min(980px, 95vw);
max-height: min(85vh, 760px);
}
.docTemplateModalBody {
display: flex;
gap: 0;
padding: 0;
overflow: hidden;
min-height: 0;
}
.docTemplateList {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--doc-border);
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.docTemplateCard {
display: block;
width: 100%;
text-align: left;
border: 1px solid var(--doc-border);
border-radius: 10px;
background: var(--doc-card);
color: var(--doc-text);
padding: 10px 12px;
cursor: pointer;
}
.docTemplateCard.is-active {
border-color: var(--doc-accent);
box-shadow: 0 0 0 1px var(--doc-accent);
}
.docTemplateCardName {
font-size: 13.5px;
font-weight: 700;
}
.docTemplateCardCategory {
font-size: 11.5px;
color: var(--doc-muted);
margin-top: 2px;
}
.docTemplatePreviewPane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--doc-bg);
}
.docTemplatePreviewFrame {
flex: 1;
min-height: 0;
border: none;
display: none;
}
.docTemplatePreviewFrame.is-visible {
display: block;
}
.docTemplatePreviewEmpty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--doc-muted);
font-size: 13px;
text-align: center;
padding: 24px;
}
.docTemplatePreviewActions {
flex-shrink: 0;
display: flex;
gap: 10px;
padding: 14px 16px;
border-top: 1px solid var(--doc-border);
}
.docTemplatePreviewActions[hidden] {
display: none;
}
.docTemplatePreviewActions .docBtnSecondary {
flex: 1;
justify-content: center;
}
@media (width <= 720px) {
.docTemplateModalBody {
flex-direction: column;
}
.docTemplateList {
width: 100%;
max-height: 160px;
border-right: none;
border-bottom: 1px solid var(--doc-border);
}
}
+102
View File
@@ -2839,6 +2839,107 @@ function forgeDocBindScenarioTreeModal() {
}); });
} }
/* ---------------------------------------------------------------------
* Modale "Utiliser un modèle" — liste les thèmes du catalogue (voir
* document_engine/themes/theme_catalog.py, transmis tel quel via
* FORGE_DOCUMENT.themes) et prévisualise RÉELLEMENT le thème sélectionné
* dans un <iframe> pointant sur /document/<slug>/theme/<id>/preview
* (jamais un style recopié à la main côté client — ce qui est
* prévisualisé est exactement ce qui s'affichera une fois appliqué).
* ------------------------------------------------------------------- */
// eslint-disable-next-line no-var, vars-on-top
var forgeDocSelectedTemplateId = null;
function forgeDocRenderTemplateList() {
const list = document.getElementById('docTemplateList');
const themes = window.FORGE_DOCUMENT.themes || [];
if (!themes.length) {
list.innerHTML = '<div class="docEmptyState">Aucun modèle disponible pour le moment.</div>';
return;
}
list.innerHTML = themes.map((t) => `
<button type="button" class="docTemplateCard ${t.id === forgeDocSelectedTemplateId ? 'is-active' : ''}" data-theme-id="${t.id}">
<div class="docTemplateCardName">${forgeDocEscapeHtml(t.name)}</div>
<div class="docTemplateCardCategory">${forgeDocEscapeHtml(t.category)}</div>
</button>
`).join('');
list.querySelectorAll('.docTemplateCard').forEach((btn) => {
btn.addEventListener('click', () => forgeDocSelectTemplate(btn.dataset.themeId));
});
}
function forgeDocSelectTemplate(themeId) {
forgeDocSelectedTemplateId = themeId;
forgeDocRenderTemplateList();
const frame = document.getElementById('docTemplatePreviewFrame');
const empty = document.getElementById('docTemplatePreviewEmpty');
const actions = document.getElementById('docTemplatePreviewActions');
frame.src = `/document/${window.forgeDocState.slug}/theme/${themeId}/preview`;
frame.classList.add('is-visible');
empty.hidden = true;
actions.hidden = false;
}
function forgeDocApplyTemplate(mode) {
if (!forgeDocSelectedTemplateId) return;
if (mode === 'template_content') {
const confirmed = window.confirm( // eslint-disable-line no-alert
'Remplacer tout le contenu actuel du document par le contenu de démonstration de ce modèle ? Cette action est irréversible.',
);
if (!confirmed) return;
}
fetch(`/document/${window.forgeDocState.slug}/theme/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme_id: forgeDocSelectedTemplateId, mode }),
}).then((r) => r.json()).then((result) => {
if (result.error) { alert(result.error); return; } // eslint-disable-line no-alert
// Le thème change le CSS chargé en <head> (voir templates/document/
// document_edit.html, extra_head) et potentiellement tout le contenu
// (mode "template_content") — un rechargement complet est le seul
// moyen simple de rester synchronisé avec la vérité serveur, jamais
// une resynchronisation partielle de l'état client ici.
window.location.reload();
});
}
function forgeDocOpenTemplateModal() {
document.getElementById('docTemplateModal').classList.add('is-open');
forgeDocSelectedTemplateId = window.FORGE_DOCUMENT.activeThemeId || null;
forgeDocRenderTemplateList();
const frame = document.getElementById('docTemplatePreviewFrame');
const empty = document.getElementById('docTemplatePreviewEmpty');
const actions = document.getElementById('docTemplatePreviewActions');
if (forgeDocSelectedTemplateId) {
forgeDocSelectTemplate(forgeDocSelectedTemplateId);
} else {
frame.classList.remove('is-visible');
frame.src = 'about:blank';
empty.hidden = false;
actions.hidden = true;
}
}
function forgeDocCloseTemplateModal() {
document.getElementById('docTemplateModal').classList.remove('is-open');
document.getElementById('docTemplatePreviewFrame').src = 'about:blank';
}
function forgeDocBindTemplateModal() {
document.getElementById('docTemplateBtn').addEventListener('click', forgeDocOpenTemplateModal);
document.getElementById('docTemplateModalClose').addEventListener('click', forgeDocCloseTemplateModal);
document.getElementById('docTemplateModal').addEventListener('click', (e) => {
if (e.target.id === 'docTemplateModal') forgeDocCloseTemplateModal();
});
document.getElementById('docTemplateKeepContentBtn').addEventListener('click', () => forgeDocApplyTemplate('keep_content'));
document.getElementById('docTemplateUseContentBtn').addEventListener('click', () => forgeDocApplyTemplate('template_content'));
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (document.getElementById('docTemplateModal').classList.contains('is-open')) forgeDocCloseTemplateModal();
});
}
function forgeDocRenderProps(el) { function forgeDocRenderProps(el) {
const panel = document.getElementById('docPropsPanel'); const panel = document.getElementById('docPropsPanel');
if (!el) { if (!el) {
@@ -3032,6 +3133,7 @@ function forgeDocInit() {
forgeDocBindMobileNav(); forgeDocBindMobileNav();
forgeDocBindKeyboardShortcuts(); forgeDocBindKeyboardShortcuts();
forgeDocBindScenarioTreeModal(); forgeDocBindScenarioTreeModal();
forgeDocBindTemplateModal();
forgeDocBindSidebarTabs(); forgeDocBindSidebarTabs();
forgeDocRenderPageManagerList(); forgeDocRenderPageManagerList();
forgeDocBindPageManagerDrag(); forgeDocBindPageManagerDrag();
@@ -0,0 +1,169 @@
/* Thème "Sécurité Incendie" (document_engine/themes/theme_catalog.py) —
habille UNIQUEMENT le contenu (jamais .docPage lui-même : format A4
paysage, fond blanc et espacement restent la mécanique du moteur, voir
static/document/document-editor.css — un thème ne les redéfinit
jamais, sous peine de casser le format garanti de la page). Chargé
APRÈS document-editor.css (voir templates/document/
document_edit.html et document_theme_preview.html), donc prioritaire
à spécificité égale sur les quelques couleurs de contenu que le
moteur pose par défaut (--forge-text, --doc-text). */
.docPageContent {
--theme-ink: #241f1c;
--theme-ink-muted: #6b6259;
--theme-line: #e7dfd2;
--theme-safety-red: #c8102e;
--theme-safety-red-dark: #8f0b20;
--theme-safety-red-tint: #fbe9ec;
--theme-hazard-amber: #e3a72e;
--theme-hazard-amber-dark: #a97815;
--theme-ok-green: #2e7d46;
--theme-font-display: "Oswald", sans-serif;
--theme-font-body: "Source Sans 3", sans-serif;
font-family: var(--theme-font-body);
--forge-text: var(--theme-ink);
--forge-text-muted: var(--theme-ink-muted);
}
.docText[data-kind="titre"] {
font-family: var(--theme-font-display);
letter-spacing: 0.01em;
}
.docText[data-kind="paragraphe"] {
font-family: var(--theme-font-body);
}
/* ---- Étiquette (badge) : encart compact type avertissement/repère ---- */
.docBadge {
display: inline-block;
font-family: var(--theme-font-body);
font-size: 12.5px;
font-weight: 600;
line-height: 1.4;
color: var(--theme-safety-red-dark);
background: var(--theme-safety-red-tint);
padding: 8px 12px;
border-radius: 7px;
}
/* ---- Liste à puces / numérotée : coche sécurité / pastille panneau ---- */
.docList {
font-family: var(--theme-font-body);
}
.docList li {
background: #faf7f2;
border: 1px solid var(--theme-line);
border-left: 3px solid var(--theme-hazard-amber);
border-radius: 7px;
}
.docList[data-kind="liste_puces"] li::before {
content: "";
flex-shrink: 0;
width: 16px;
height: 16px;
margin-top: 1px;
border-radius: 4px;
background: var(--theme-ok-green);
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9 16.2l-3.5-3.5L4 14.2l5 5 11-11-1.5-1.5z"/></svg>') center / 11px no-repeat;
}
.docList[data-kind="liste_numerotee"] {
counter-reset: doc-theme-step;
}
.docList[data-kind="liste_numerotee"] li::before {
counter-increment: doc-theme-step;
content: counter(doc-theme-step);
flex-shrink: 0;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--theme-safety-red);
color: #fff;
font-family: var(--theme-font-display);
font-size: 12px;
font-weight: 600;
}
.docList[data-kind="liste_numerotee"] li {
border-left-color: var(--theme-safety-red);
}
/* ---- Carte : classes de feu (repère en pastille + titre + description) ---- */
.docCard {
background: #faf7f2;
border: 1px solid var(--theme-line);
border-radius: 9px;
}
.docCardLabel {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-family: var(--theme-font-display);
font-size: 15px;
font-weight: 700;
color: #fff;
background: var(--theme-ink-muted);
}
.docCardTitle {
font-family: var(--theme-font-body);
font-size: 12px;
font-weight: 700;
color: var(--theme-ink);
}
.docCardDescription {
font-family: var(--theme-font-body);
font-size: 10.5px;
color: var(--theme-ink-muted);
line-height: 1.3;
}
/* ---- Bouton d'action ---- */
.docButton {
font-family: var(--theme-font-display);
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
background: var(--theme-safety-red);
color: #fff;
box-shadow: 0 6px 16px -6px rgb(200 16 46 / 55%);
}
/* ---- Image : cadre discret pour un pictogramme dessiné ---- */
.docImage {
background: #fdf3de;
border: 1px solid var(--theme-line);
}
/* ---- Quiz : palette rouge sécurité sur fond blanc ---- */
.docQuizKicker {
font-family: var(--theme-font-display);
color: var(--theme-safety-red);
}
.docQuizPlayerTitle {
font-family: var(--theme-font-display);
}
.docQuizOption {
background: #faf7f2;
border-color: var(--theme-line);
}
.docQuizOptionLetter {
font-family: var(--theme-font-display);
}
+54 -1
View File
@@ -5,6 +5,13 @@
{% block extra_head %} {% block extra_head %}
<link rel="stylesheet" <link rel="stylesheet"
href="{{ url_for('static', filename='document/document-editor.css') }}"> href="{{ url_for('static', filename='document/document-editor.css') }}">
{% if active_theme %}
{% if active_theme.font_url %}
<link rel="stylesheet" href="{{ active_theme.font_url }}">
{% endif %}
<link rel="stylesheet"
href="{{ url_for('static', filename=active_theme.css_path) }}">
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="docEditor3" id="docEditor3" data-slug="{{ support.slug }}"> <div class="docEditor3" id="docEditor3" data-slug="{{ support.slug }}">
@@ -76,6 +83,23 @@
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg><span class="docBtnLabel">Aperçu</span> </svg><span class="docBtnLabel">Aperçu</span>
</button> </button>
<button type="button"
class="docBtnSecondary"
id="docTemplateBtn">
<svg width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg><span class="docBtnLabel">Utiliser un modèle</span>
</button>
<div class="docDividerV"></div> <div class="docDividerV"></div>
<div class="docWidthPicker" <div class="docWidthPicker"
id="docWidthPicker" id="docWidthPicker"
@@ -226,13 +250,42 @@
<div class="docModalBody" id="docScenarioTreeModalBody"></div> <div class="docModalBody" id="docScenarioTreeModalBody"></div>
</div> </div>
</div> </div>
<div class="docModalBackdrop" id="docTemplateModal">
<div class="docModalDialog docModalDialog--wide">
<div class="docModalHeader">
<span class="docModalTitle">Utiliser un modèle</span>
<button type="button"
class="docModalCloseBtn"
id="docTemplateModalClose"
aria-label="Fermer">✕</button>
</div>
<div class="docModalBody docTemplateModalBody">
<div class="docTemplateList" id="docTemplateList"></div>
<div class="docTemplatePreviewPane">
<iframe class="docTemplatePreviewFrame"
id="docTemplatePreviewFrame"
title="Aperçu du modèle"></iframe>
<div class="docTemplatePreviewEmpty"
id="docTemplatePreviewEmpty">Sélectionnez un modèle pour le visualiser.</div>
<div class="docTemplatePreviewActions"
id="docTemplatePreviewActions"
hidden>
<button type="button" class="docBtnSecondary" id="docTemplateKeepContentBtn">Utiliser mon contenu actuel</button>
<button type="button" class="docBtnSecondary docBtnPrimary" id="docTemplateUseContentBtn">Utiliser le contenu du modèle</button>
</div>
</div>
</div>
</div>
</div>
<script> <script>
window.FORGE_DOCUMENT = { window.FORGE_DOCUMENT = {
slug: {{ support.slug|tojson }}, slug: {{ support.slug|tojson }},
elements: {{ elements|tojson }}, elements: {{ elements|tojson }},
elementKindLabels: {{ element_kind_labels|tojson }}, elementKindLabels: {{ element_kind_labels|tojson }},
pages: {{ pages|tojson }}, pages: {{ pages|tojson }},
activePageId: {{ active_page.id|tojson }} activePageId: {{ active_page.id|tojson }},
themes: {{ document_themes|tojson }},
activeThemeId: {{ support.theme|tojson }}
}; };
</script> </script>
<script src="{{ url_for('static', filename='document/js/document-editor.js') }}"></script> <script src="{{ url_for('static', filename='document/js/document-editor.js') }}"></script>
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Aperçu — {{ theme.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='document/document-editor.css') }}">
{% if theme.font_url %}
<link rel="stylesheet" href="{{ theme.font_url }}">
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename=theme.css_path) }}">
<style>
html, body {
margin: 0;
padding: 16px;
background: #efece4;
display: flex;
justify-content: center;
}
</style>
</head>
<body>
<div class="docPage">
<div class="docPageContent">
{{ rendered_page | safe }}
</div>
</div>
</body>
</html>
+46
View File
@@ -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: def test_bouton_with_attachment_filename_renders_data_attribute(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup) slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id) element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
+63
View File
@@ -400,3 +400,66 @@ def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient,
element_id = _add_bouton(client, support) element_id = _add_bouton(client, support)
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment") resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
assert resp.status_code == 404 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
+74
View File
@@ -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"
)
+23
View File
@@ -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] assert [s["slug"] for s in db.list_supports("48")] == [slug]
db.delete_support(slug) db.delete_support(slug)
assert db.list_supports("48") == [] 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"
+12
View File
@@ -26,3 +26,15 @@ from core import (
) )
_ = (auth_guard, csrf, csrf_guard, db_teardown_guard, jinja_filters, recovery_codes_flash) _ = (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 <slug>/<theme_id> 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