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>
466 lines
20 KiB
Python
466 lines
20 KiB
Python
"""Routes HTTP du support de formation (routes/document/) — création,
|
|
édition directe (sans page intermédiaire), CRUD des éléments en AJAX,
|
|
publication, et isolation par propriétaire (core/auth_guard.py).
|
|
|
|
Chaque élément appartient désormais à une page (voir document_engine/
|
|
pages/ et test_document_pages_routes.py pour les routes dédiées aux
|
|
pages elles-mêmes) — _page_id() renvoie l'id de la page par défaut
|
|
("Page 1") créée avec chaque support de test, réutilisé par toutes les
|
|
routes d'élément ci-dessous qui exigent désormais un page_id explicite."""
|
|
|
|
import io
|
|
from typing import Any
|
|
|
|
from flask.testing import FlaskClient
|
|
|
|
import auth
|
|
import db
|
|
import document_engine
|
|
|
|
|
|
def _page_id(support: str) -> int:
|
|
return int(document_engine.list_document_pages(support)[0]["id"])
|
|
|
|
|
|
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
|
|
resp = client.post("/documents/new", data={"name": "Sécurité incendie"}, follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
assert "/document/" in resp.headers["Location"]
|
|
assert "/edit" in resp.headers["Location"]
|
|
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
|
|
try:
|
|
assert db.support_meta(slug)["name"] == "Sécurité incendie"
|
|
finally:
|
|
db.delete_support(slug)
|
|
|
|
|
|
def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient, support: str) -> None:
|
|
resp = client.get(f"/document/{support}/edit")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
|
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["kind"] == "titre"
|
|
assert "docText" in payload["rendered_html"]
|
|
|
|
|
|
def test_document_element_add_rejects_unknown_kind(client: FlaskClient, support: str) -> None:
|
|
resp = client.post(
|
|
f"/document/{support}/elements/add", data={"kind": "n-importe-quoi", "page_id": _page_id(support)}
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_document_element_add_rejects_a_missing_or_unknown_page(client: FlaskClient, support: str) -> None:
|
|
assert client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).status_code == 404
|
|
assert client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": 999}).status_code == 404
|
|
|
|
|
|
def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskClient, support: str) -> None:
|
|
""" "row" n'apparaît jamais dans la bibliothèque (voir
|
|
document_engine.ELEMENT_LIBRARY) mais reste un kind valide pour cette
|
|
route — c'est le moteur de layout côté client qui en crée une à la
|
|
volée au moment d'un dépôt latéral (voir
|
|
static/document/js/document-editor.js)."""
|
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": _page_id(support)})
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["kind"] == "row"
|
|
|
|
|
|
def test_document_element_add_creates_a_bullet_list(client: FlaskClient, support: str) -> None:
|
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "liste_puces", "page_id": _page_id(support)})
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["kind"] == "liste_puces"
|
|
assert "<ul" in payload["rendered_html"]
|
|
assert "docList" in payload["rendered_html"]
|
|
|
|
|
|
def test_document_element_add_creates_a_numbered_list(client: FlaskClient, support: str) -> None:
|
|
resp = client.post(
|
|
f"/document/{support}/elements/add", data={"kind": "liste_numerotee", "page_id": _page_id(support)}
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["kind"] == "liste_numerotee"
|
|
assert "<ol" in payload["rendered_html"]
|
|
|
|
|
|
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
|
|
page_id = _page_id(support)
|
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id})
|
|
element_id = add_resp.get_json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/document/{support}/elements/{element_id}/update",
|
|
json={"content": "Texte mis à jour", "style": "legende"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["ok"] is True
|
|
|
|
reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
|
|
assert reread.status_code == 200
|
|
|
|
|
|
def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskClient, support: str) -> None:
|
|
"""Quiz et Association sont revalidés côté serveur (voir
|
|
routes/document/document_element_update.py) : un choix en trop
|
|
(5e) doit être tronqué, et la réponse renvoie les attributs
|
|
RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le
|
|
client ne dérive jamais de la vérité serveur."""
|
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)})
|
|
element_id = add_resp.get_json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/document/{support}/elements/{element_id}/update",
|
|
json={
|
|
"theme_color": "#ff5f2e",
|
|
"timer_enabled": False,
|
|
"timer_seconds": 30,
|
|
"questions": [
|
|
{"text": "Une question", "choices": ["A", "B", "C", "D", "E"], "correct_index": 0, "points": 10},
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["ok"] is True
|
|
assert payload["attributes"]["questions"][0]["choices"] == ["A", "B", "C", "D"]
|
|
assert "1 question" in payload["rendered_html"]
|
|
|
|
|
|
def test_document_element_update_sanitizes_association_config_on_write(client: FlaskClient, support: str) -> None:
|
|
add_resp = client.post(
|
|
f"/document/{support}/elements/add", data={"kind": "association", "page_id": _page_id(support)}
|
|
)
|
|
element_id = add_resp.get_json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/document/{support}/elements/{element_id}/update",
|
|
json={
|
|
"theme_color": "#ffb020",
|
|
"pairs": [
|
|
{"left": "Chat", "right": "Un félin"},
|
|
{"left": "Sans correspondance", "right": ""},
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["ok"] is True
|
|
assert payload["attributes"]["pairs"] == [{"left": "Chat", "right": "Un félin"}]
|
|
assert "1 paire" in payload["rendered_html"]
|
|
|
|
|
|
def test_document_element_update_sanitizes_memory_config_on_write(client: FlaskClient, support: str) -> None:
|
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "memory", "page_id": _page_id(support)})
|
|
element_id = add_resp.get_json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/document/{support}/elements/{element_id}/update",
|
|
json={
|
|
"theme_color": "#ffb020",
|
|
"mode": "single",
|
|
"cards": [
|
|
{"recto": {"text": "?"}, "verso": {"text": "Chat"}},
|
|
{"recto": {"text": "?"}, "verso": {"image": "", "text": ""}},
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["ok"] is True
|
|
assert payload["attributes"]["mode"] == "single"
|
|
assert len(payload["attributes"]["cards"]) == 1
|
|
assert "1 carte" in payload["rendered_html"]
|
|
assert "mode simple" in payload["rendered_html"]
|
|
|
|
|
|
def test_document_edit_sanitizes_stale_scenario_attributes_from_an_old_schema(
|
|
client: FlaskClient, support: str
|
|
) -> None:
|
|
"""Bug réel constaté le 21/09/2026 : un élément Scénario créé AVANT la
|
|
refonte en arbre de décision (ancien schéma plat "situation"/"choices"/
|
|
"correct_index", sans "title"/"nodes") faisait planter silencieusement
|
|
le panneau Propriétés côté client (`scenario.nodes` inexistant), sans
|
|
qu'aucune erreur ne remonte — voir document_engine/labels/
|
|
sanitize_element_attributes.py. `update_document_element_attributes`
|
|
(bas niveau, jamais utilisée directement par une route) sert ici à
|
|
écrire ce schéma obsolète tel quel, en contournant volontairement le
|
|
sanitize de la route d'update — pour simuler une ligne réellement
|
|
ancienne en base, jamais nettoyée depuis."""
|
|
page_id = _page_id(support)
|
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "scenario", "page_id": page_id})
|
|
element_id = add_resp.get_json()["id"]
|
|
stale_attributes = {
|
|
"theme_color": "#ff5f2e",
|
|
"scenarios": [
|
|
{
|
|
"situation": "Un chat va se faire renverser sous vos yeux, que faites-vous ?",
|
|
"choices": [{"text": "Je le sauve", "consequence": "Le chat est sauvé."}],
|
|
"correct_index": 0,
|
|
}
|
|
],
|
|
}
|
|
document_engine.update_document_element_attributes(support, element_id, stale_attributes)
|
|
|
|
edit_resp = client.get(f"/document/{support}/edit")
|
|
assert edit_resp.status_code == 200
|
|
render_resp = client.get(f"/document/{support}/render", query_string={"page_id": page_id})
|
|
assert render_resp.status_code == 200
|
|
rendered_element = next(el for el in render_resp.get_json()["elements"] if el["id"] == element_id)
|
|
# Le vieux schéma n'a ni "title" ni "nodes" valides : sanitize_scenario_config
|
|
# le rejette entièrement plutôt que de renvoyer une structure à moitié
|
|
# ancienne/à moitié nouvelle — c'est la structure ACTUELLE garantie qui
|
|
# compte ici, jamais un plantage silencieux côté client.
|
|
assert rendered_element["attributes"] == document_engine.DEFAULT_SCENARIO_CONFIG
|
|
|
|
|
|
def test_document_render_rejects_a_missing_or_unknown_page(client: FlaskClient, support: str) -> None:
|
|
assert client.get(f"/document/{support}/render").status_code == 404
|
|
assert client.get(f"/document/{support}/render", query_string={"page_id": 999}).status_code == 404
|
|
|
|
|
|
def test_document_element_add_rejects_a_minigame_on_a_non_empty_page(client: FlaskClient, support: str) -> None:
|
|
"""Retour utilisateur du 23/09/2026 : "un mini jeu dois occupper
|
|
toute une page" -> "une page avec mini-jeu = uniquement ce
|
|
mini-jeu". Un mini-jeu ne peut donc jamais rejoindre une page qui a
|
|
déjà du contenu."""
|
|
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}/elements/add", data={"kind": "quiz", "page_id": page_id})
|
|
assert resp.status_code == 400
|
|
assert "mini-jeu" in resp.get_json()["error"]
|
|
|
|
|
|
def test_document_element_add_rejects_other_content_on_a_page_with_a_minigame(
|
|
client: FlaskClient, support: str
|
|
) -> None:
|
|
page_id = _page_id(support)
|
|
client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id})
|
|
|
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
|
|
assert resp.status_code == 400
|
|
assert "mini-jeu" in resp.get_json()["error"]
|
|
|
|
|
|
def test_document_element_add_rejects_a_minigame_inside_a_row(client: FlaskClient, support: str) -> None:
|
|
page_id = _page_id(support)
|
|
row_id = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": page_id}).get_json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id, "parent_id": row_id}
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_document_element_add_allows_a_lone_minigame_on_an_empty_page(client: FlaskClient, support: str) -> None:
|
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)})
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
|
|
page_id = _page_id(support)
|
|
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}).get_json()[
|
|
"id"
|
|
]
|
|
second_id = client.post(
|
|
f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id}
|
|
).get_json()["id"]
|
|
|
|
move_resp = client.post(
|
|
f"/document/{support}/elements/{second_id}/move",
|
|
json={"new_parent_id": None, "new_index": 0},
|
|
)
|
|
assert move_resp.status_code == 200
|
|
assert move_resp.get_json()["ok"] is True
|
|
|
|
delete_resp = client.post(f"/document/{support}/elements/{first_id}/delete")
|
|
assert delete_resp.status_code == 200
|
|
assert delete_resp.get_json()["ok"] is True
|
|
|
|
|
|
def test_document_element_move_to_page_transfers_the_element(client: FlaskClient, support: str) -> None:
|
|
"""Retour utilisateur du 23/09/2026 : pagination automatique — un
|
|
élément qui déborde d'une page est déplacé vers une autre, ici la
|
|
route qui porte ce déplacement (jamais document_element_move, qui
|
|
ne fait que réordonner DANS la même page)."""
|
|
page1_id = _page_id(support)
|
|
page2_id = document_engine.add_document_page(support)
|
|
element_id = client.post(
|
|
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page1_id}
|
|
).get_json()["id"]
|
|
|
|
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": page2_id})
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["ok"] is True
|
|
moved = document_engine.get_document_element(support, element_id)
|
|
assert moved is not None
|
|
assert moved["page_id"] == page2_id
|
|
|
|
|
|
def test_document_element_move_to_page_rejects_an_unknown_target(client: FlaskClient, support: str) -> None:
|
|
page_id = _page_id(support)
|
|
element_id = client.post(
|
|
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}
|
|
).get_json()["id"]
|
|
|
|
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": 999})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_restricted_user_can_have_one_game_and_one_support_at_once(
|
|
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
|
|
) -> None:
|
|
with user_client.session_transaction() as sess:
|
|
user_id = sess["user_id"]
|
|
game_slug = db.create_game("Un environnement 2D", owner_folder=str(user_id))
|
|
tmp_game_slug_cleanup(game_slug)
|
|
auth.set_project_slug(user_id, game_slug)
|
|
|
|
resp = user_client.post("/documents/new", data={"name": "Un support"}, follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
support_slug = resp.headers["Location"].rstrip("/").split("/")[-2]
|
|
tmp_support_slug_cleanup(support_slug)
|
|
|
|
user = auth.get_user_by_id(user_id)
|
|
assert user is not None
|
|
assert user["project_slug"] == game_slug
|
|
assert user["support_slug"] == support_slug
|
|
|
|
|
|
def test_cannot_open_another_owners_support(client: FlaskClient, user_client: FlaskClient) -> None:
|
|
"""Isolation par propriétaire (core/auth_guard.py, split_slug déjà
|
|
générique quel que soit le type de projet) — le compte admin
|
|
(`client`) ne doit pas pouvoir ouvrir le support d'un autre compte."""
|
|
with user_client.session_transaction() as sess:
|
|
victim_user_id = sess["user_id"]
|
|
victim_slug = db.create_support("Support de la victime", owner_folder=str(victim_user_id))
|
|
try:
|
|
assert client.get(f"/document/{victim_slug}/edit").status_code == 403
|
|
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
|
|
finally:
|
|
db.delete_support(victim_slug)
|
|
|
|
|
|
def _add_bouton(client: FlaskClient, support: str) -> int:
|
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "bouton", "page_id": _page_id(support)})
|
|
return int(resp.get_json()["id"])
|
|
|
|
|
|
def test_upload_attachment_stores_file_and_updates_button_attributes(client: FlaskClient, support: str) -> None:
|
|
element_id = _add_bouton(client, support)
|
|
resp = client.post(
|
|
f"/document/{support}/elements/{element_id}/upload-attachment",
|
|
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
|
|
content_type="multipart/form-data",
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["attributes"]["attachment_filename"] == "fiche-consignes.pdf"
|
|
assert payload["attributes"]["attachment_stored_name"]
|
|
assert 'data-attachment-filename="fiche-consignes.pdf"' in payload["rendered_html"]
|
|
|
|
|
|
def test_upload_attachment_rejects_a_non_bouton_element(client: FlaskClient, support: str) -> None:
|
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
|
|
element_id = resp.get_json()["id"]
|
|
resp = client.post(
|
|
f"/document/{support}/elements/{element_id}/upload-attachment",
|
|
data={"file": (io.BytesIO(b"peu importe"), "x.pdf")},
|
|
content_type="multipart/form-data",
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_upload_attachment_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
|
|
element_id = _add_bouton(client, support)
|
|
resp = client.post(f"/document/{support}/elements/{element_id}/upload-attachment", data={})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_download_attachment_serves_the_file_under_its_original_name(client: FlaskClient, support: str) -> None:
|
|
element_id = _add_bouton(client, support)
|
|
client.post(
|
|
f"/document/{support}/elements/{element_id}/upload-attachment",
|
|
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
|
|
content_type="multipart/form-data",
|
|
)
|
|
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
|
|
assert resp.status_code == 200
|
|
assert resp.data == b"%PDF-1.4 fake pdf content"
|
|
assert "fiche-consignes.pdf" in resp.headers["Content-Disposition"]
|
|
|
|
|
|
def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient, support: str) -> None:
|
|
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
|