Dev #14
@@ -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,
|
||||
|
||||
@@ -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 "",
|
||||
}
|
||||
@@ -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()
|
||||
@@ -238,6 +238,26 @@ def build_scorm_zip(slug):
|
||||
# seule personne à la fois sur son poste, comme /game/<slug>/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 "</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("</", "<\\/"),
|
||||
)
|
||||
html = _relativize_absolute_urls(html, slug)
|
||||
html = _inline_icon_urls(html, payload["icon_data_uris"])
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from . import export_scorm
|
||||
from . import xapi_settings
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/xapi-settings", methods=["GET"])
|
||||
def xapi_settings_get(slug):
|
||||
"""Réglages xAPI de ce jeu (voir db/games/get_xapi_settings.py) —
|
||||
jamais le mot de passe en clair, juste `password_set` (voir la
|
||||
modale d'export, templates/base.html, qui pré-remplit endpoint/login
|
||||
mais laisse toujours le champ mot de passe vide)."""
|
||||
settings = db.get_xapi_settings(slug)
|
||||
return jsonify({
|
||||
"endpoint": settings["endpoint"],
|
||||
"login": settings["login"],
|
||||
"password_set": bool(settings["password"]),
|
||||
})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/xapi-settings", methods=["POST"])
|
||||
def xapi_settings_set(slug):
|
||||
"""Enregistre les réglages xAPI de ce jeu — voir
|
||||
db/games/set_xapi_settings.py : un mot de passe absent/vide dans le
|
||||
corps JSON laisse celui déjà enregistré inchangé plutôt que de
|
||||
l'effacer."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
endpoint = (body.get("endpoint") or "").strip()
|
||||
login = (body.get("login") or "").strip()
|
||||
password = body.get("password") or None
|
||||
db.set_xapi_settings(slug, endpoint, login, password)
|
||||
return jsonify({"ok": True})
|
||||
@@ -117,6 +117,11 @@ function forgeSyncQuizScoreToScorm() {
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
gameData.scoring.score = window.forgeQuizScore;
|
||||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin
|
||||
// quête/quiz contourne static/js/play/offline/apply-actions.js (aucune
|
||||
// action de flow "Modifier un score" ici), donc jamais notifié sans cet
|
||||
// appel direct — même bug que le commentaire ci-dessus pour SCORM.
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
||||
}
|
||||
|
||||
// Une quête acceptée = la partie a commencé, même sans le moindre point
|
||||
@@ -135,6 +140,7 @@ function forgeSyncAllQuestsCompletionToScorm() {
|
||||
if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return;
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
gameData.scoring.status = 'termine';
|
||||
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged('termine');
|
||||
}
|
||||
|
||||
function forgeAddQuizScore(amount) {
|
||||
|
||||
@@ -62,6 +62,10 @@ function forgeApplyScoreActionOffline(gameData, action) {
|
||||
return false;
|
||||
}
|
||||
gameData.scoring.score = newValue;
|
||||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : no-op si
|
||||
// le fichier n'est pas chargé (tests Node via module.exports ci-dessous,
|
||||
// ou réglage xAPI jamais configuré pour ce jeu).
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(newValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -70,6 +74,7 @@ function forgeApplyStatusActionOffline(gameData, action) {
|
||||
if (!status || FORGE_SCORE_STATUS_KEYS.indexOf(status) === -1) return false;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
|
||||
gameData.scoring.status = status;
|
||||
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(status);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Export Web/SCORM — bolt-on xAPI (voir publish/build_scorm_package.py,
|
||||
// db/games/get_xapi_settings.py) : EN PLUS du SCORM classique
|
||||
// (scorm-api.js, jamais touché par ce fichier), envoie des statements
|
||||
// xAPI directement au LRS configuré par le créateur du jeu. Chargé
|
||||
// TOUJOURS dans le paquet exporté (même sans réglage xAPI — voir
|
||||
// templates/play.html), mais ne fait RIEN tant que
|
||||
// window.FORGE_XAPI_CONFIG est `null` (réglage jamais renseigné).
|
||||
//
|
||||
// Volontairement PAS le protocole cmi5 (pas de négociation de jeton au
|
||||
// lancement, pas de découverte d'endpoint via l'URL du LMS) : l'URL du
|
||||
// LRS et les identifiants sont saisis une fois pour toutes par le
|
||||
// créateur (voir routes/publish/xapi_settings.py) et embarqués tels
|
||||
// quels dans le paquet — un LRS quelconque suffit, jamais besoin que le
|
||||
// LMS du client supporte cmi5 lui-même.
|
||||
//
|
||||
// Best-effort partout : un LRS injoignable/mal configuré ne doit JAMAIS
|
||||
// bloquer ni ralentir la partie du joueur.
|
||||
|
||||
// Statut Forge -> verbe xAPI ADL standard, uniquement pour les statuts
|
||||
// qui marquent une VRAIE fin de partie — "non_commence"/"en_cours" ne
|
||||
// génèrent rien ici (bruit inutile pour un suivi volontairement léger,
|
||||
// voir le plan).
|
||||
var FORGE_TO_XAPI_VERB = {
|
||||
termine: { id: "http://adlnet.gov/expapi/verbs/completed", display: "completed" },
|
||||
reussi: { id: "http://adlnet.gov/expapi/verbs/passed", display: "passed" },
|
||||
echoue: { id: "http://adlnet.gov/expapi/verbs/failed", display: "failed" },
|
||||
};
|
||||
|
||||
var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
|
||||
|
||||
// Identité de l'apprenant : réutilise l'API SCORM déjà trouvée par
|
||||
// scorm-api.js (window.forgeScormApi, posée sur "load" — voir plus bas,
|
||||
// notre propre handler "load" s'enregistre APRÈS le sien, donc after lui)
|
||||
// si le paquet tourne dans un LMS ; sinon, un acteur anonyme générique.
|
||||
function forgeXapiActor() {
|
||||
try {
|
||||
if (typeof forgeScormApi !== "undefined" && forgeScormApi) {
|
||||
var name = forgeScormApi.LMSGetValue("cmi.core.student_name");
|
||||
var id = forgeScormApi.LMSGetValue("cmi.core.student_id");
|
||||
if (id) {
|
||||
return { objectType: "Agent", name: name || id, account: { homePage: "urn:forge-engine", name: id } };
|
||||
}
|
||||
}
|
||||
} catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ }
|
||||
return { objectType: "Agent", name: "Apprenant", mbox: "mailto:anonymous@forge-engine.local" };
|
||||
}
|
||||
|
||||
function forgeXapiObject() {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id,
|
||||
definition: { name: { "fr-FR": config.activity_name } },
|
||||
};
|
||||
}
|
||||
|
||||
// verbId/verbDisplay : voir FORGE_TO_XAPI_VERB, ou directement les
|
||||
// verbes "initialized"/"terminated" (posés une seule fois chacun, voir
|
||||
// plus bas) — result : objet xAPI "result" optionnel (score/completion/
|
||||
// success), fusionné tel quel dans le statement.
|
||||
function forgeXapiSendStatement(verbId, verbDisplay, result) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
if (!config || !config.endpoint) return;
|
||||
var statement = {
|
||||
actor: forgeXapiActor(),
|
||||
verb: { id: verbId, display: { "en-US": verbDisplay } },
|
||||
object: forgeXapiObject(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (result) statement.result = result;
|
||||
var endpoint = config.endpoint.replace(/\/+$/, "") + "/statements";
|
||||
try {
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Experience-API-Version": "1.0.3",
|
||||
"Authorization": "Basic " + btoa(config.login + ":" + config.password),
|
||||
},
|
||||
body: JSON.stringify(statement),
|
||||
}).catch(function (err) {
|
||||
console.warn("xAPI: envoi du statement échoué (LRS injoignable ?)", err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("xAPI: impossible d'envoyer le statement", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Rappelées directement par static/js/play/offline/apply-actions.js après
|
||||
// chaque mutation RÉELLE (jamais un sondage périodique, contrairement à
|
||||
// scorm-api.js::forgeScormSyncScore) — un seul statement par changement.
|
||||
function forgeXapiNotifyScoreChanged(newScore) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/scored", "scored",
|
||||
{ score: { raw: newScore } }
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyStatusChanged(newStatus) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
var verb = FORGE_TO_XAPI_VERB[newStatus];
|
||||
if (!verb || newStatus === forgeXapiLastStatusSent) return;
|
||||
forgeXapiLastStatusSent = newStatus;
|
||||
forgeXapiSendStatement(verb.id, verb.display, {
|
||||
completion: true,
|
||||
success: newStatus !== "echoue",
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", function () {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/initialized", "initialized");
|
||||
});
|
||||
window.addEventListener("beforeunload", function () {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/terminated", "terminated");
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB };
|
||||
}
|
||||
+83
-3
@@ -114,14 +114,44 @@
|
||||
<div class="box">
|
||||
<h2 class="title is-4">Exporter « {{ game.name }} » en Web/SCORM</h2>
|
||||
<p class="hint mb-4">Construit un paquet SCORM 1.2 (imsmanifest.xml + page web autonome, aucun serveur requis) : importe le .zip obtenu directement dans ton LMS (Moodle...) — le score et le statut de la partie remontent automatiquement.</p>
|
||||
<div id="scormProgress">
|
||||
<!-- Suivi xAPI (voir publish/build_scorm_package.py, routes/publish/
|
||||
xapi_settings.py) : greffé sur le SCORM ci-dessus, jamais à sa
|
||||
place — un réglage jamais renseigné (endpoint vide) désactive
|
||||
entièrement xAPI pour ce jeu, le SCORM continue seul. Réglé
|
||||
séparément du bouton d'export : ces valeurs sont déjà en base
|
||||
au moment où on clique "Exporter", jamais retapées à chaque export. -->
|
||||
<details class="propGroup mb-4" id="xapiSettingsGroup">
|
||||
<summary>Suivi xAPI (optionnel)</summary>
|
||||
<div class="propGroupBody">
|
||||
<p class="help mb-2">Envoie en plus les résultats vers un LRS (Learning Record Store) externe, en parallèle du SCORM ci-dessus. Utilise une clé API dédiée à cet export, jamais tes identifiants LRS principaux.</p>
|
||||
<div class="field">
|
||||
<label class="label is-small">URL du LRS</label>
|
||||
<div class="control"><input class="input is-small" type="text" id="xapiEndpoint" placeholder="https://mon-lrs.exemple.com/xAPI"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">Login</label>
|
||||
<div class="control"><input class="input is-small" type="text" id="xapiLogin" autocomplete="off"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">Mot de passe</label>
|
||||
<div class="control"><input class="input is-small" type="password" id="xapiPassword" autocomplete="new-password"></div>
|
||||
<p class="help" id="xapiPasswordHint"></p>
|
||||
</div>
|
||||
<div class="field is-grouped" style="align-items:center;">
|
||||
<div class="control"><button type="button" class="button is-small" onclick="window.forgeSaveXapiSettings()">Enregistrer</button></div>
|
||||
<span class="hint" id="xapiSaveStatus"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div id="scormProgress" style="display:none;">
|
||||
<p class="hint" id="scormStepText">Préparation du jeu...</p>
|
||||
<div class="passwordStrengthBar" style="margin-top:10px;"><div class="passwordStrengthFill strong" id="scormProgressFill" style="width:100%; animation:forgePublishPulse 1.2s ease-in-out infinite;"></div></div>
|
||||
</div>
|
||||
<div id="scormError" class="notification is-danger" style="display:none;"></div>
|
||||
<div id="scormDone" class="notification is-success" style="display:none;">Téléchargement lancé — importe directement le .zip dans ton LMS.</div>
|
||||
<div class="field mt-4" style="display:flex; justify-content:flex-end;">
|
||||
<div class="field mt-4" style="display:flex; justify-content:flex-end; gap:8px;">
|
||||
<button type="button" class="button is-small" onclick="window.forgeCloseScormModal()">Fermer</button>
|
||||
<button type="button" class="button primary is-small" id="scormExportButton" onclick="window.forgeStartScormExport()">Exporter (Web/SCORM)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,13 +166,63 @@
|
||||
var steps = ["Préparation du jeu...", "Génération du paquet hors ligne...", "Écriture du manifeste SCORM..."];
|
||||
var stepTimer = null;
|
||||
|
||||
var xapiEndpoint = document.getElementById('xapiEndpoint');
|
||||
var xapiLogin = document.getElementById('xapiLogin');
|
||||
var xapiPassword = document.getElementById('xapiPassword');
|
||||
var xapiPasswordHint = document.getElementById('xapiPasswordHint');
|
||||
var xapiSaveStatus = document.getElementById('xapiSaveStatus');
|
||||
|
||||
function forgeLoadXapiSettings() {
|
||||
fetch("{{ url_for('xapi_settings_get', slug=game.slug) }}")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
xapiEndpoint.value = json.endpoint || '';
|
||||
xapiLogin.value = json.login || '';
|
||||
xapiPassword.value = '';
|
||||
xapiPasswordHint.textContent = json.password_set
|
||||
? 'Mot de passe déjà enregistré — laisse ce champ vide pour le garder.' : '';
|
||||
});
|
||||
}
|
||||
|
||||
// Réglages, jamais retapés à chaque export : sauvegardés indépendamment
|
||||
// du bouton "Exporter" ci-dessous (voir routes/publish/xapi_settings.py).
|
||||
window.forgeSaveXapiSettings = function () {
|
||||
xapiSaveStatus.textContent = 'Enregistrement...';
|
||||
fetch("{{ url_for('xapi_settings_set', slug=game.slug) }}", {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: xapiEndpoint.value.trim(), login: xapiLogin.value.trim(),
|
||||
password: xapiPassword.value || null,
|
||||
}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
xapiSaveStatus.textContent = json.ok ? 'Enregistré ✓' : 'Échec de l\'enregistrement.';
|
||||
forgeLoadXapiSettings();
|
||||
})
|
||||
.catch(function () { xapiSaveStatus.textContent = 'Échec de l\'enregistrement.'; });
|
||||
};
|
||||
|
||||
// Ouvrir la modale ne lance PLUS l'export tout de suite : le temps que
|
||||
// l'utilisateur renseigne (et surtout enregistre, bouton séparé
|
||||
// ci-dessus) ses réglages xAPI serait sinon une course contre le fetch
|
||||
// d'export, qui pourrait partir avec des réglages pas encore sauvegardés.
|
||||
// L'export est déclenché explicitement via forgeStartScormExport().
|
||||
window.forgeOpenScormModal = function () {
|
||||
progressBox.style.display = 'none';
|
||||
errorBox.style.display = 'none';
|
||||
doneBox.style.display = 'none';
|
||||
xapiSaveStatus.textContent = '';
|
||||
forgeLoadXapiSettings();
|
||||
modal.classList.add('is-active');
|
||||
};
|
||||
|
||||
window.forgeStartScormExport = function () {
|
||||
var stepIndex = 0;
|
||||
stepText.textContent = steps[0];
|
||||
progressBox.style.display = '';
|
||||
errorBox.style.display = 'none';
|
||||
doneBox.style.display = 'none';
|
||||
modal.classList.add('is-active');
|
||||
|
||||
stepTimer = setInterval(function () {
|
||||
stepIndex = (stepIndex + 1) % steps.length;
|
||||
|
||||
@@ -121,6 +121,12 @@
|
||||
// sur static/js/play/offline/*.js (ci-dessous) au lieu d'un fetch().
|
||||
window.FORGE_OFFLINE = true;
|
||||
var FORGE_PLAY_URLS = {};
|
||||
// xAPI (voir publish/build_scorm_package.py::build_scorm_zip,
|
||||
// static/js/play/offline/xapi-client.js) : bolt-on greffé sur le SCORM
|
||||
// classique ci-dessous (scorm_api_wrapper_url), jamais lié à lui — un
|
||||
// paquet sans réglage LRS renvoie `null` ici, xapi-client.js ne fait
|
||||
// alors rien.
|
||||
window.FORGE_XAPI_CONFIG = {{ xapi_config_json|safe }};
|
||||
{% else %}
|
||||
var FORGE_PLAY_URLS = {
|
||||
runtimePayload: {{ runtime_payload_url|tojson }},
|
||||
@@ -146,6 +152,7 @@
|
||||
<script src="{{ asset_url('js/play/offline/render-element.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/offline/full-payload.js') }}"></script>
|
||||
{% if scorm_api_wrapper_url %}<script src="{{ scorm_api_wrapper_url }}"></script>{% endif %}
|
||||
<script src="{{ asset_url('js/play/offline/xapi-client.js') }}"></script>
|
||||
{% endif %}
|
||||
<script src="{{ asset_url('js/play/actions.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
|
||||
|
||||
@@ -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/<slug>/publish (core/auth_guard.py) —
|
||||
aucune vérification supplémentaire écrite pour cette route."""
|
||||
|
||||
@@ -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/<slug>/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")
|
||||
Reference in New Issue
Block a user