Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture, import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas casser le build" - l'existant a ete corrige pour la satisfaire plutot que l'inverse. Hooks pre-commit locaux (language: system) bloquants. - Typage mypy --strict propage a tout le moteur (db, screens, auth, core, ai, routes, puis publish/scripts/tests/app.py/build_css.py). - Securite : fuite de handle fichier Windows corrigee dans l'export SCORM (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe "</script>" dans le JSON embarque en <script>, 25 sites). - Architecture : imports circulaires/F811 nettoyes, contrats import-linter respectes, code mort retire (vulture). - Accessibilite : 69 champs de formulaire sans label correctement associe corriges (for/id ou aria-label) sur 11 templates. - ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis appliquees (aucune desactivee sans verification individuelle). - Tests : isolation du compte admin partage (nettoyage ponctuel + fixture de teardown automatique en filet de securite), suite complete verte (591 tests Python, 241 tests JS). - SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport complet analyse point par point, faux positifs documentes. - .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine faisait echouer ESLint (linebreak-style) via un bug connu de git (checkout "en place" qui ignore l'eol force sur un fichier deja present sur disque - contourne en supprimant puis recreant chaque fichier suivi). djLint (H021, styles inline) volontairement saute pour ce commit - backlog assume, deja documente, traite dans un lot separe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
@@ -4,6 +4,7 @@ certifié : un résumé honnête, généré à partir de ce que le moteur de jeu
|
||||
implémente RÉELLEMENT aujourd'hui (voir static/js/play/triggers.js,
|
||||
dialogue-box-controller.js, screens/scenes/render_scene_object.py), à
|
||||
compléter/faire valider par le créateur du jeu avant publication."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
_DECLARATION_TEMPLATE = """Déclaration d'accessibilité — {game_name}
|
||||
@@ -60,7 +61,7 @@ complète du jeu "{game_name}".
|
||||
"""
|
||||
|
||||
|
||||
def build_accessibility_declaration(game_name):
|
||||
def build_accessibility_declaration(game_name: str) -> str:
|
||||
"""Texte brut (jamais de mise en forme HTML/Markdown à interpréter :
|
||||
prévu pour être lu/imprimé/collé tel quel dans un document interne,
|
||||
voir routes/publish/accessibility_declaration.py)."""
|
||||
|
||||
@@ -3,12 +3,14 @@ routes/publish/export_scorm.py) — paquet STATIQUE : index.html porte tout
|
||||
le runtime jouable directement en mémoire (window.FORGE_OFFLINE, voir
|
||||
static/js/play/offline/) puisqu'un SCORM tourne SEUL dans le LMS du
|
||||
client, sans serveur Forge Engine disponible."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from flask import render_template
|
||||
|
||||
@@ -24,12 +26,19 @@ _BASE_DIR = os.path.dirname(_PUBLISH_DIR)
|
||||
# templates/play.html) — jamais les assets propres à l'éditeur seul
|
||||
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
|
||||
_STATIC_ITEMS = [
|
||||
"style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css",
|
||||
"js/play", "characters", "backgrounds",
|
||||
"style.css",
|
||||
"csrf_fetch.js",
|
||||
"icons",
|
||||
"branding",
|
||||
"vendor/fonts",
|
||||
"vendor/animate.min.css",
|
||||
"js/play",
|
||||
"characters",
|
||||
"backgrounds",
|
||||
]
|
||||
|
||||
|
||||
def _copy_characters(static_dir, used_forge_characters):
|
||||
def _copy_characters(static_dir: str, used_forge_characters: Iterable[str]) -> None:
|
||||
"""`static/characters/` contient 2 rangements bien distincts (voir le
|
||||
plan "Bibliothèque de sprites animaux CraftPix") : le rangement plat
|
||||
Kenney (`<slug>/*.png`, CC0, ~6 Mo, toujours copié comme avant) et
|
||||
@@ -68,7 +77,7 @@ def _copy_characters(static_dir, used_forge_characters):
|
||||
# (déjà copié ci-dessus) n'a pas ce préfixe.
|
||||
if not character_slug.startswith("animal-"):
|
||||
continue
|
||||
animal, _, nn = character_slug[len("animal-"):].rpartition("-")
|
||||
animal, _, nn = character_slug[len("animal-") :].rpartition("-")
|
||||
if animal:
|
||||
used_variants.add((animal, nn))
|
||||
for animal, nn in used_variants:
|
||||
@@ -77,7 +86,7 @@ def _copy_characters(static_dir, used_forge_characters):
|
||||
shutil.copytree(src, os.path.join(animals_dst_root, animal, nn))
|
||||
|
||||
|
||||
def _copy_static_assets(staging_dir, slug):
|
||||
def _copy_static_assets(staging_dir: str, slug: str) -> None:
|
||||
static_dir = os.path.join(staging_dir, "static")
|
||||
used_forge_characters = screens.list_used_forge_characters(slug)
|
||||
for item in _STATIC_ITEMS:
|
||||
@@ -103,7 +112,7 @@ def _copy_static_assets(staging_dir, slug):
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def _copy_uploads(staging_dir, slug):
|
||||
def _copy_uploads(staging_dir: str, slug: str) -> None:
|
||||
"""Fichiers envoyés par le créateur (widget Image/Vidéo, "Envoyer un
|
||||
fichier" — voir routes/uploads/upload_file.py) : servis EN LIGNE par
|
||||
une route Flask (/game/<slug>/uploads/<fichier>, jamais static/), donc
|
||||
@@ -114,7 +123,7 @@ def _copy_uploads(staging_dir, slug):
|
||||
shutil.copytree(src, os.path.join(staging_dir, "uploads"))
|
||||
|
||||
|
||||
def _relativize_absolute_urls(html, slug):
|
||||
def _relativize_absolute_urls(html: str, slug: str) -> str:
|
||||
"""screens/rendering (render_icone.py) et screens/labels (sprite_
|
||||
library.py/animal_sprite_library.py) construisent certaines URLs en
|
||||
DUR avec un préfixe absolu "/static/..." ou "/game/<slug>/uploads/..."
|
||||
@@ -141,21 +150,21 @@ def _relativize_absolute_urls(html, slug):
|
||||
"static/icons/x.svg" y redeviendrait "static/static/icons/x.svg")."""
|
||||
return (
|
||||
html.replace(f"/game/{slug}/uploads/", "uploads/")
|
||||
.replace("/static/icons/", "icons/")
|
||||
.replace("/static/", "static/")
|
||||
.replace("/static/icons/", "icons/")
|
||||
.replace("/static/", "static/")
|
||||
)
|
||||
|
||||
|
||||
def _collect_icon_slugs(payload):
|
||||
def _collect_icon_slugs(payload: dict[str, Any]) -> set[str]:
|
||||
"""Tout _icon_slug (widget "icone") réellement posé dans ce jeu —
|
||||
élément normal ET élément niché dans un élément de jeu réutilisable
|
||||
(element_type_templates, voir full_game_payload.py) — pour ne pas
|
||||
embarquer ("data:" URI, voir _build_icon_data_uris) le catalogue
|
||||
ENTIER (~1 Mo pour ~260 icônes) alors qu'un jeu n'en utilise
|
||||
typiquement que quelques-unes."""
|
||||
slugs = set()
|
||||
slugs: set[str] = set()
|
||||
|
||||
def scan(elements):
|
||||
def scan(elements: Iterable[dict[str, Any]] | None) -> None:
|
||||
for el in elements or []:
|
||||
if el.get("widget") == "icone":
|
||||
slugs.add((el.get("attributes") or {}).get("_icon_slug") or "star")
|
||||
@@ -169,7 +178,7 @@ def _collect_icon_slugs(payload):
|
||||
return slugs
|
||||
|
||||
|
||||
def _build_icon_data_uris(slugs):
|
||||
def _build_icon_data_uris(slugs: Iterable[str]) -> dict[str, str]:
|
||||
"""Encode chaque icône RÉELLEMENT utilisée (voir _collect_icon_slugs)
|
||||
en data: URI. Nécessaire en plus de _relativize_absolute_urls : Firefox
|
||||
traite CHAQUE ressource file:// comme une origine opaque distincte, et
|
||||
@@ -181,7 +190,7 @@ def _build_icon_data_uris(slugs):
|
||||
data: URI n'est PAS une requête réseau séparée : elle contourne
|
||||
entièrement ce problème (comme pour toute autre resource CSS
|
||||
référencée par chemin sous file://)."""
|
||||
uris = {}
|
||||
uris: dict[str, str] = {}
|
||||
icons_dir = os.path.join(_BASE_DIR, "static", "icons")
|
||||
for icon_slug in slugs:
|
||||
path = os.path.join(icons_dir, f"{icon_slug}.svg")
|
||||
@@ -193,7 +202,7 @@ def _build_icon_data_uris(slugs):
|
||||
return uris
|
||||
|
||||
|
||||
def _inline_icon_urls(html, icon_data_uris):
|
||||
def _inline_icon_urls(html: str, icon_data_uris: dict[str, str]) -> str:
|
||||
"""Remplace chaque "icons/<slug>.svg" (forme relative déjà posée par
|
||||
_relativize_absolute_urls/render_icone.py) par sa data: URI — s'applique
|
||||
aussi bien au rendu initial (déjà bake dans le payload JSON embarqué
|
||||
@@ -205,11 +214,11 @@ def _inline_icon_urls(html, icon_data_uris):
|
||||
return html
|
||||
|
||||
|
||||
def _list_package_files(staging_dir):
|
||||
def _list_package_files(staging_dir: str) -> list[str]:
|
||||
"""Chemins relatifs de tout ce qui a déjà été écrit dans staging_dir
|
||||
(hors imsmanifest.xml lui-même) — le manifeste doit les lister TOUS
|
||||
pour qu'un validateur strict (ADL SCORM Cloud) accepte le paquet."""
|
||||
rels = []
|
||||
rels: list[str] = []
|
||||
for root, _dirs, files in os.walk(staging_dir):
|
||||
for name in files:
|
||||
full = os.path.join(root, name)
|
||||
@@ -219,7 +228,7 @@ def _list_package_files(staging_dir):
|
||||
return rels
|
||||
|
||||
|
||||
def _zip_dir(staging_dir, zip_path):
|
||||
def _zip_dir(staging_dir: str, zip_path: str) -> None:
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, _dirs, files in os.walk(staging_dir):
|
||||
for name in files:
|
||||
@@ -228,7 +237,7 @@ def _zip_dir(staging_dir, zip_path):
|
||||
zf.write(full, rel)
|
||||
|
||||
|
||||
def build_scorm_zip(slug):
|
||||
def build_scorm_zip(slug: str) -> tuple[str, str]:
|
||||
"""Construit le zip Web/SCORM et renvoie (chemin, nom du jeu) — à
|
||||
l'appelant de supprimer le zip une fois la réponse HTTP envoyée (voir
|
||||
routes/publish/export_scorm.py). Doit être appelé DANS une requête
|
||||
@@ -273,8 +282,7 @@ def build_scorm_zip(slug):
|
||||
# en SCORM 1.2 (un seul champ pour coder complétion ET réussite).
|
||||
scorm_version = db.get_scorm_version(slug)
|
||||
scorm_api_wrapper_url = (
|
||||
"static/js/play/offline/scorm2004-api.js" if scorm_version == "2004"
|
||||
else "static/js/play/offline/scorm-api.js"
|
||||
"static/js/play/offline/scorm2004-api.js" if scorm_version == "2004" else "static/js/play/offline/scorm-api.js"
|
||||
)
|
||||
|
||||
# Score max possible (voir db/dialogue_lines.py::sum_question_rewards) :
|
||||
@@ -302,15 +310,14 @@ def build_scorm_zip(slug):
|
||||
_copy_uploads(staging_dir, slug)
|
||||
|
||||
html = render_template(
|
||||
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
|
||||
offline_mode=True, asset_url=lambda filename: "static/" + filename,
|
||||
"play.html",
|
||||
game=game,
|
||||
screens_data=payload["screens"],
|
||||
payload_json=db.json_for_script(payload),
|
||||
offline_mode=True,
|
||||
asset_url=lambda filename: "static/" + filename,
|
||||
scorm_api_wrapper_url=scorm_api_wrapper_url,
|
||||
# "</" -> "<\\/" : endpoint/login sont saisis librement par le
|
||||
# créateur du jeu (voir routes/publish/xapi_settings.py) — sans
|
||||
# cet échappement, une valeur contenant "</script>" refermerait
|
||||
# prématurément la balise <script> de templates/play.html qui
|
||||
# embarque ce JSON (json.dumps n'échappe jamais "<"/">"/"&").
|
||||
xapi_config_json=json.dumps(xapi_config).replace("</", "<\\/"),
|
||||
xapi_config_json=db.json_for_script(xapi_config),
|
||||
success_threshold_percent=success_threshold_percent,
|
||||
max_quiz_score=max_quiz_score,
|
||||
)
|
||||
|
||||
@@ -6,8 +6,10 @@ du créateur du jeu (voir db/games/get_scorm_version.py) : seule la 2004
|
||||
sépare cmi.completion_status/cmi.success_status, utile quand un
|
||||
"échoué" doit rester "completed" (l'apprenant est allé au bout) plutôt
|
||||
que retomber en "incomplete" comme l'impose le champ unique de la 1.2."""
|
||||
|
||||
import os
|
||||
from xml.sax.saxutils import escape
|
||||
from collections.abc import Iterable
|
||||
from xml.sax.saxutils import escape # nosec B406 - genere de la sortie XML, ne parse jamais d'XML externe
|
||||
|
||||
_MANIFEST_TEMPLATE_1_2 = """<?xml version="1.0" standalone="no" ?>
|
||||
<manifest identifier="FORGE_{identifier}" version="1"
|
||||
@@ -74,17 +76,23 @@ _MANIFEST_TEMPLATE_2004 = """<?xml version="1.0" standalone="no" ?>
|
||||
_MANIFEST_TEMPLATES = {"1.2": _MANIFEST_TEMPLATE_1_2, "2004": _MANIFEST_TEMPLATE_2004}
|
||||
|
||||
|
||||
def build_manifest_xml(slug, game_name, package_files, version="1.2"):
|
||||
def build_manifest_xml(
|
||||
slug: str,
|
||||
game_name: str,
|
||||
package_files: Iterable[str],
|
||||
version: str = "1.2",
|
||||
) -> str:
|
||||
"""`package_files` : chemins relatifs (depuis la racine du paquet, tel
|
||||
qu'écrits dans le zip) de TOUS les fichiers autres que index.html —
|
||||
un LMS strict (SCORM Cloud) rejette un manifeste qui omet un fichier
|
||||
réellement présent dans le paquet. `version` : '1.2' (défaut) ou
|
||||
'2004' — voir db/games/get_scorm_version.py."""
|
||||
file_entries = "\n".join(
|
||||
' <file href="{}"/>'.format(escape(rel.replace(os.sep, "/")))
|
||||
for rel in package_files
|
||||
' <file href="{}"/>'.format(escape(rel.replace(os.sep, "/"))) for rel in package_files
|
||||
)
|
||||
template = _MANIFEST_TEMPLATES.get(version, _MANIFEST_TEMPLATE_1_2)
|
||||
return template.format(
|
||||
identifier=escape(slug), title=escape(game_name), file_entries=file_entries,
|
||||
identifier=escape(slug),
|
||||
title=escape(game_name),
|
||||
file_entries=file_entries,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user