From f7a50e5afef22c7fc9a2c30476c145ebd126f8c6 Mon Sep 17 00:00:00 2001 From: william Date: Sat, 5 Sep 2026 10:00:57 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Ajoute=20un=20suivi=20xAPI=20optionnel=20(b?= =?UTF-8?q?olt-on)=20au=20paquet=20SCORM=20export=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greffe l'envoi de statements xAPI vers un LRS configurable par le créateur du jeu, en parallèle du reporting SCORM existant : réglages stockés en base (_meta), formulaire dans la modale d'export, injection dans le paquet exporté. Relié aussi bien à l'action de flow "Modifier un score/statut" qu'au parcours quête/quiz (qui alimentait déjà le SCORM classique via un chemin séparé). L'export ne se lance plus automatiquement à l'ouverture de la modale, pour laisser le temps d'enregistrer les réglages xAPI avant de générer le paquet. --- db/__init__.py | 2 + db/games/get_xapi_settings.py | 23 ++++ db/games/set_xapi_settings.py | 24 +++++ publish/build_scorm_package.py | 26 +++++ routes/publish/__init__.py | 1 + routes/publish/xapi_settings.py | 33 ++++++ static/js/play/dialogue-box-controller.js | 6 ++ static/js/play/offline/apply-actions.js | 5 + static/js/play/offline/xapi-client.js | 122 ++++++++++++++++++++++ templates/base.html | 86 ++++++++++++++- templates/play.html | 7 ++ tests/test_export_scorm.py | 24 +++++ tests/test_xapi_settings.py | 66 ++++++++++++ 13 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 db/games/get_xapi_settings.py create mode 100644 db/games/set_xapi_settings.py create mode 100644 routes/publish/xapi_settings.py create mode 100644 static/js/play/offline/xapi-client.js create mode 100644 tests/test_xapi_settings.py diff --git a/db/__init__.py b/db/__init__.py index 25f3a850..83579465 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -34,6 +34,8 @@ from .games.create_game import create_game from .games.update_game_name import update_game_name from .games.delete_game import delete_game from .games.move_game import move_game +from .games.get_xapi_settings import get_xapi_settings +from .games.set_xapi_settings import set_xapi_settings from .games.game_type_catalog import ( ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE, get_onboarding_type, get_onboarding_type_raw, set_onboarding_type, diff --git a/db/games/get_xapi_settings.py b/db/games/get_xapi_settings.py new file mode 100644 index 00000000..7e7cb1bf --- /dev/null +++ b/db/games/get_xapi_settings.py @@ -0,0 +1,23 @@ +from ..connection import connect + + +def get_xapi_settings(slug): + """Réglages xAPI de CE jeu (voir set_xapi_settings.py — stockés dans + _meta, même convention que 'name'/'onboarding_type', voir + db/games/game_type_catalog.py) : URL du LRS (Learning Record Store) + + identifiants, saisis une seule fois par le créateur du jeu (voir + routes/publish/xapi_settings.py) plutôt que négociés à chaque + lancement (jamais le protocole cmi5 complet, voir publish/ + build_scorm_package.py::build_scorm_zip). Chaînes vides si non + configuré — `endpoint` vide signifie "xAPI désactivé pour ce jeu".""" + conn = connect(slug) + rows = conn.execute( + "SELECT key, value FROM _meta WHERE key IN ('xapi_lrs_endpoint', 'xapi_lrs_login', 'xapi_lrs_password')" + ).fetchall() + conn.close() + values = {row["key"]: row["value"] for row in rows} + return { + "endpoint": values.get("xapi_lrs_endpoint") or "", + "login": values.get("xapi_lrs_login") or "", + "password": values.get("xapi_lrs_password") or "", + } diff --git a/db/games/set_xapi_settings.py b/db/games/set_xapi_settings.py new file mode 100644 index 00000000..0c63315b --- /dev/null +++ b/db/games/set_xapi_settings.py @@ -0,0 +1,24 @@ +from ..connection import connect + + +def set_xapi_settings(slug, endpoint, login, password=None): + """Enregistre les réglages xAPI de CE jeu (voir get_xapi_settings.py). + `password=None` (champ laissé vide côté formulaire, voir + routes/publish/xapi_settings.py) laisse le mot de passe déjà + enregistré INCHANGÉ — jamais écrasé par une chaîne vide, pour ne pas + obliger à le retaper à chaque modification de l'URL/du login, et pour + que la route GET puisse ne jamais renvoyer sa valeur au navigateur + (juste un booléen "déjà configuré").""" + conn = connect(slug) + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_endpoint', ?)", (endpoint or "",) + ) + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_login', ?)", (login or "",) + ) + if password is not None: + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,) + ) + conn.commit() + conn.close() diff --git a/publish/build_scorm_package.py b/publish/build_scorm_package.py index ed41f43c..20c7437b 100644 --- a/publish/build_scorm_package.py +++ b/publish/build_scorm_package.py @@ -238,6 +238,26 @@ def build_scorm_zip(slug): # seule personne à la fois sur son poste, comme /game//play — # jamais d'état "par joueur" à distinguer hors ligne. payload = screens.full_game_payload(slug) + + # xAPI (voir db/games/get_xapi_settings.py, routes/publish/ + # xapi_settings.py) : bolt-on volontairement simple, JAMAIS le + # protocole cmi5 complet (pas de négociation de jeton au lancement) — + # les identifiants du LRS sont saisis une fois par le créateur du jeu + # et embarqués tels quels dans le paquet exporté, lu par + # static/js/play/offline/xapi-client.js. `endpoint` vide (réglage + # jamais renseigné) désactive entièrement xAPI pour ce jeu : le + # SCORM classique (scorm-api.js) continue de fonctionner à l'identique + # dans tous les cas. + xapi_settings = db.get_xapi_settings(slug) + xapi_config = None + if xapi_settings["endpoint"]: + xapi_config = { + "endpoint": xapi_settings["endpoint"], + "login": xapi_settings["login"], + "password": xapi_settings["password"], + "activity_id": f"urn:forge-engine:game:{slug}", + "activity_name": game["name"], + } # gameData.icon_data_uris (voir forgeRenderIcone, # static/js/play/offline/render-special-widgets.js) : ajouté au # payload AVANT le rendu pour qu'il fasse partie du même payload_json @@ -253,6 +273,12 @@ def build_scorm_zip(slug): "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), offline_mode=True, asset_url=lambda filename: "static/" + filename, scorm_api_wrapper_url="static/js/play/offline/scorm-api.js", + # " "<\\/" : endpoint/login sont saisis librement par le + # créateur du jeu (voir routes/publish/xapi_settings.py) — sans + # cet échappement, une valeur contenant "" refermerait + # prématurément la balise {% if scorm_api_wrapper_url %}{% endif %} + {% endif %} diff --git a/tests/test_export_scorm.py b/tests/test_export_scorm.py index 7f46d9e2..c8ca32a4 100644 --- a/tests/test_export_scorm.py +++ b/tests/test_export_scorm.py @@ -8,6 +8,7 @@ plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite automatisée).""" import zipfile +import db from tests.test_auth import anon_client # noqa: F401 @@ -59,6 +60,29 @@ def test_export_scorm_manifest_lists_index_html_as_the_sco(client, game): assert "adlcp:scormtype=\"sco\"" in manifest +def test_export_scorm_index_html_has_no_xapi_config_when_unconfigured(client, game): + resp = client.post(f"/game/{game}/export-scorm") + import io + with zipfile.ZipFile(io.BytesIO(resp.data)) as zf: + html = zf.read("index.html").decode("utf-8") + assert "window.FORGE_XAPI_CONFIG = null;" in html + # Toujours chargé, même sans réglage (no-op tant que la config est null). + assert 'src="static/js/play/offline/xapi-client.js"' in html + + +def test_export_scorm_index_html_embeds_the_xapi_config_when_set(client, game): + db.set_xapi_settings(game, "https://lrs.example.com/xapi/", "monlogin", "monmotdepasse") + resp = client.post(f"/game/{game}/export-scorm") + import io + with zipfile.ZipFile(io.BytesIO(resp.data)) as zf: + html = zf.read("index.html").decode("utf-8") + assert "window.FORGE_XAPI_CONFIG = {" in html + assert "https://lrs.example.com/xapi/" in html + assert "monlogin" in html + assert "monmotdepasse" in html + assert 'src="static/js/play/offline/xapi-client.js"' in html + + def test_export_scorm_route_is_isolated_like_other_game_routes(anon_client): """Même garde d'accès que /game//publish (core/auth_guard.py) — aucune vérification supplémentaire écrite pour cette route.""" diff --git a/tests/test_xapi_settings.py b/tests/test_xapi_settings.py new file mode 100644 index 00000000..4294e01e --- /dev/null +++ b/tests/test_xapi_settings.py @@ -0,0 +1,66 @@ +"""Réglages xAPI (bolt-on, voir db/games/get_xapi_settings.py, +routes/publish/xapi_settings.py) — le mot de passe ne doit JAMAIS +ressortir en clair (seulement un booléen `password_set`), et un POST +sans mot de passe ne doit pas écraser celui déjà enregistré.""" +from tests.test_auth import anon_client # noqa: F401 + + +def test_xapi_settings_get_defaults_to_empty(client, game): + resp = client.get(f"/game/{game}/xapi-settings") + assert resp.status_code == 200 + body = resp.get_json() + assert body == {"endpoint": "", "login": "", "password_set": False} + + +def test_xapi_settings_post_then_get_round_trip(client, game): + resp = client.post(f"/game/{game}/xapi-settings", json={ + "endpoint": "https://lrs.example.com/xapi/", + "login": "monlogin", + "password": "monmotdepasse", + }) + assert resp.status_code == 200 + assert resp.get_json() == {"ok": True} + + resp = client.get(f"/game/{game}/xapi-settings") + body = resp.get_json() + assert body["endpoint"] == "https://lrs.example.com/xapi/" + assert body["login"] == "monlogin" + assert body["password_set"] is True + assert "password" not in body + + +def test_xapi_settings_post_with_blank_password_keeps_the_existing_one(client, game): + client.post(f"/game/{game}/xapi-settings", json={ + "endpoint": "https://lrs.example.com/xapi/", + "login": "monlogin", + "password": "monmotdepasse", + }) + + resp = client.post(f"/game/{game}/xapi-settings", json={ + "endpoint": "https://lrs.example.com/xapi/v2/", + "login": "monlogin", + "password": "", + }) + assert resp.status_code == 200 + + import db + settings = db.get_xapi_settings(game) + assert settings["endpoint"] == "https://lrs.example.com/xapi/v2/" + assert settings["password"] == "monmotdepasse" + + +def test_xapi_settings_route_is_isolated_like_other_game_routes(anon_client): + """Même garde d'accès que /game//publish (core/auth_guard.py) — + aucune vérification supplémentaire écrite pour cette route.""" + from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project + + _register(anon_client, "xapisettingsisolation@example.com") + _confirm_2fa(anon_client) + _complete_onboarding(anon_client) + try: + resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/xapi-settings") + assert resp.status_code == 403 + resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/xapi-settings", json={}) + assert resp.status_code == 403 + finally: + _cleanup_project("xapisettingsisolation@example.com") -- 2.54.0 From c504ace1671c26eb53fc77c43e612f5808693b39 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 6 Sep 2026 00:17:51 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Enrichit=20le=20reporting=20SCORM/xAPI=20et?= =?UTF-8?q?=20met=20en=20conformit=C3=A9=20RGAA=20le=20player?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCORM/xAPI : - Ajoute l'export SCORM 2004 (3rd/4th edition) au choix, en plus du 1.2 par défaut : sépare completion_status/success_status (un échec reste "completed" au lieu de retomber à tort en "incomplete" comme force la 1.2), remonte aussi cmi.interactions.n.* par question répondue. - Fournit cmi.core.score.min/max (1.2 et 2004), calculé depuis les récompenses de quiz du jeu, pour que le LMS affiche un vrai pourcentage au lieu du score brut à tort étiqueté "%". - Libellés de verbes xAPI en français en plus de l'anglais. Accessibilité (RGAA/WCAG 2.1 AA) sur le player : - Navigation clavier des objets de scène "au clic"/"au survol" (tabindex, role, Entrée/Espace, focus/blur). - alt sur les images (nom auteur ou décoratif), aria-hidden sur les icônes seules, role="dialog"/aria-live sur les boîtes de dialogue/quiz. - Landmark
+ titre de page, respect de prefers-reduced-motion. - Le quiz n'avance plus automatiquement après un délai fixe : bouton "Continuer →" explicite (RGAA 2.2.1). - Avertissement de contraste dans l'éditeur de style de dialogue. - Déclaration d'accessibilité téléchargeable depuis la modale d'export. --- db/__init__.py | 4 + db/games/get_scorm_version.py | 19 +++ db/games/get_success_threshold.py | 19 +++ db/games/set_scorm_version.py | 15 ++ db/games/set_success_threshold.py | 14 ++ publish/accessibility_declaration.py | 67 ++++++++ publish/build_scorm_package.py | 37 +++- publish/scorm_manifest.py | 58 ++++++- routes/publish/__init__.py | 3 + routes/publish/accessibility_declaration.py | 20 +++ routes/publish/scoring_settings.py | 26 +++ routes/publish/scorm_version_settings.py | 23 +++ screens/scenes/render_scene_object.py | 23 ++- .../__tests__/dialogue-box-controller.test.js | 158 ++++++++++++++++-- static/js/play/dialogue-box-controller.js | 90 +++++++--- .../offline/__tests__/render-element.test.js | 12 ++ .../__tests__/render-special-widgets.test.js | 7 + .../play/offline/__tests__/scorm-api.test.js | 76 +++++++++ .../offline/__tests__/scorm2004-api.test.js | 151 +++++++++++++++++ .../offline/__tests__/xapi-client.test.js | 93 +++++++++++ static/js/play/offline/render-element.js | 16 +- .../js/play/offline/render-special-widgets.js | 8 + static/js/play/offline/scorm-api.js | 21 ++- static/js/play/offline/scorm2004-api.js | 136 +++++++++++++++ static/js/play/offline/xapi-client.js | 117 +++++++++++-- static/js/play/triggers.js | 39 ++++- static/js/scenes/scene-editor.js | 46 +++++ static/style.css | 8 + templates/base.html | 95 ++++++++++- templates/play.html | 27 ++- templates/scene_edit.html | 6 + tests/test_accessibility_declaration.py | 35 ++++ tests/test_export_scorm.py | 64 +++++++ tests/test_scene_objects.py | 32 ++++ tests/test_scoring_settings.py | 51 ++++++ tests/test_scorm_version_settings.py | 44 +++++ 36 files changed, 1581 insertions(+), 79 deletions(-) create mode 100644 db/games/get_scorm_version.py create mode 100644 db/games/get_success_threshold.py create mode 100644 db/games/set_scorm_version.py create mode 100644 db/games/set_success_threshold.py create mode 100644 publish/accessibility_declaration.py create mode 100644 routes/publish/accessibility_declaration.py create mode 100644 routes/publish/scoring_settings.py create mode 100644 routes/publish/scorm_version_settings.py create mode 100644 static/js/play/offline/__tests__/scorm-api.test.js create mode 100644 static/js/play/offline/__tests__/scorm2004-api.test.js create mode 100644 static/js/play/offline/__tests__/xapi-client.test.js create mode 100644 static/js/play/offline/scorm2004-api.js create mode 100644 tests/test_accessibility_declaration.py create mode 100644 tests/test_scoring_settings.py create mode 100644 tests/test_scorm_version_settings.py diff --git a/db/__init__.py b/db/__init__.py index 83579465..884dd607 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -36,6 +36,10 @@ from .games.delete_game import delete_game from .games.move_game import move_game from .games.get_xapi_settings import get_xapi_settings from .games.set_xapi_settings import set_xapi_settings +from .games.get_success_threshold import get_success_threshold +from .games.set_success_threshold import set_success_threshold +from .games.get_scorm_version import get_scorm_version +from .games.set_scorm_version import set_scorm_version from .games.game_type_catalog import ( ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE, get_onboarding_type, get_onboarding_type_raw, set_onboarding_type, diff --git a/db/games/get_scorm_version.py b/db/games/get_scorm_version.py new file mode 100644 index 00000000..fbe0b086 --- /dev/null +++ b/db/games/get_scorm_version.py @@ -0,0 +1,19 @@ +from ..connection import connect + +DEFAULT_SCORM_VERSION = "1.2" +VALID_SCORM_VERSIONS = ("1.2", "2004") + + +def get_scorm_version(slug): + """Version SCORM exportée pour ce jeu (voir publish/build_scorm_package.py, + publish/scorm_manifest.py) — '1.2' par défaut (compatibilité la plus + large) ou '2004' (3rd/4th edition, sépare completion_status/ + success_status : voir static/js/play/offline/scorm2004-api.js, corrige + la limite SCORM 1.2 où un statut unique doit coder à la fois + complétion ET réussite).""" + conn = connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'scorm_version'").fetchone() + conn.close() + if row is None or row["value"] not in VALID_SCORM_VERSIONS: + return DEFAULT_SCORM_VERSION + return row["value"] diff --git a/db/games/get_success_threshold.py b/db/games/get_success_threshold.py new file mode 100644 index 00000000..8f5b5cbb --- /dev/null +++ b/db/games/get_success_threshold.py @@ -0,0 +1,19 @@ +from ..connection import connect + +DEFAULT_SUCCESS_THRESHOLD_PERCENT = 70 + + +def get_success_threshold(slug): + """Seuil de réussite (% de bonnes réponses au quiz) de CE jeu — stocké + dans _meta (clé 'success_threshold_percent'), même convention que + get_xapi_settings.py. Détermine le statut SCORM/xAPI de fin de partie + (reussi/echoue, voir static/js/play/dialogue-box-controller.js:: + forgeSyncAllQuestsCompletionToScorm) : 70% par défaut si jamais réglé.""" + conn = connect(slug) + row = conn.execute( + "SELECT value FROM _meta WHERE key = 'success_threshold_percent'" + ).fetchone() + conn.close() + if row is None or row["value"] in (None, ""): + return DEFAULT_SUCCESS_THRESHOLD_PERCENT + return int(row["value"]) diff --git a/db/games/set_scorm_version.py b/db/games/set_scorm_version.py new file mode 100644 index 00000000..21279a6d --- /dev/null +++ b/db/games/set_scorm_version.py @@ -0,0 +1,15 @@ +from ..connection import connect +from .get_scorm_version import VALID_SCORM_VERSIONS + + +def set_scorm_version(slug, version): + """Enregistre la version SCORM exportée — voir get_scorm_version.py.""" + if version not in VALID_SCORM_VERSIONS: + raise ValueError("version SCORM invalide : {!r}".format(version)) + conn = connect(slug) + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('scorm_version', ?)", + (version,), + ) + conn.commit() + conn.close() diff --git a/db/games/set_success_threshold.py b/db/games/set_success_threshold.py new file mode 100644 index 00000000..cb327628 --- /dev/null +++ b/db/games/set_success_threshold.py @@ -0,0 +1,14 @@ +from ..connection import connect + + +def set_success_threshold(slug, percent): + """Enregistre le seuil de réussite (0-100) — voir get_success_threshold.py.""" + percent = max(0, min(100, int(percent))) + conn = connect(slug) + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('success_threshold_percent', ?)", + (str(percent),), + ) + conn.commit() + conn.close() + return percent diff --git a/publish/accessibility_declaration.py b/publish/accessibility_declaration.py new file mode 100644 index 00000000..7ddd975e --- /dev/null +++ b/publish/accessibility_declaration.py @@ -0,0 +1,67 @@ +"""Déclaration d'accessibilité exportable (obligation légale RGAA pour +tout service numérique concerné, voir roadmap phase 1.2) — PAS un audit +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} +Générée le {today} par Forge Engine + +Cette déclaration résume l'état d'accessibilité du jeu de formation +"{game_name}" au regard du RGAA (Référentiel Général d'Amélioration de +l'Accessibilité, version française de WCAG 2.1 niveau AA). Elle décrit ce +que le moteur Forge Engine implémente pour TOUT jeu exporté — elle ne +remplace pas un audit RGAA complet réalisé par un expert accessibilité, et +ne couvre pas le contenu propre à ce jeu (texte, images, choix de +couleurs) qui reste sous la responsabilité de son créateur. + +CRITÈRES PRIS EN CHARGE PAR LE MOTEUR +-------------------------------------- +- Navigation clavier : tout objet de scène réagissant "Au clic" ou "Au + survol" est focusable (Tab) et activable au clavier (Entrée/Espace), + en plus de la souris. Les boutons de dialogue/quiz sont de vrais +