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>
361 lines
19 KiB
HTML
361 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr" data-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{% block title %}Forge Engine{% endblock %}</title>
|
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='branding/forge-favicon.svg') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
|
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
|
|
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
|
|
{% block extra_head %}{% endblock %}
|
|
</head>
|
|
<body class="{% block body_class %}{% endblock %}">
|
|
<!-- #pageChrome : tout ce qui est en dehors de <main> mais doit quand même
|
|
changer d'une page à l'autre (barre du jeu comprise) — un seul bloc
|
|
stable pour que pjax.js puisse le remplacer d'un coup à chaque
|
|
navigation (voir swapDocument() dans pjax.js). Avant l'ajout de ce
|
|
conteneur, pjax.js cherchait un <header class="topbar"> qui n'a jamais
|
|
existé (la barre est un <nav>, pas un <header>) : la barre de
|
|
navigation du jeu (ajoutée après pjax.js) ne changeait donc jamais
|
|
pendant la navigation pjax — absente en arrivant sur un jeu tant qu'on
|
|
ne rechargeait pas la page en entier (Ctrl+F5), et inversement
|
|
laissée en place en revenant sur l'accueil (liste des jeux) où elle
|
|
n'a pourtant rien à faire. -->
|
|
<div id="pageChrome">
|
|
<nav class="navbar topbar" role="navigation" aria-label="main navigation">
|
|
<div class="navbar-brand">
|
|
<a href="{{ url_for('index') }}" class="navbar-item brand">
|
|
<img src="{{ url_for('static', filename='branding/forge-logo.svg') }}" alt="" width="32" height="32" style="border-radius:8px;">
|
|
<strong class="forge-gradient-title">Forge Engine</strong>
|
|
</a>
|
|
</div>
|
|
{% if g.get('current_user') %}
|
|
<div class="navbar-end" style="margin-left:auto; display:flex; align-items:center; gap:10px; padding-right:12px;">
|
|
<a href="{{ url_for('profile') }}" class="hint" style="text-decoration:underline;">{{ g.current_user.prenom }} {{ g.current_user.nom }}{{ ' (admin)' if g.current_user.role == 'admin' else '' }}</a>
|
|
<form method="post" action="{{ url_for('logout') }}"><button type="submit" class="button is-small">Déconnexion</button></form>
|
|
</div>
|
|
{% endif %}
|
|
</nav>
|
|
{% if game is defined and game %}
|
|
<!-- Barre toujours visible entre les différentes pages d'UN MÊME jeu.
|
|
Écrans/Éléments de jeu/Variables/Objets n'ont plus leur propre page —
|
|
tout ça vit désormais dans les onglets du tableau de bord (voir
|
|
game_dashboard.html) — donc un seul lien y mène. Remplace le fil
|
|
d'Ariane (retiré, devenu redondant) : n'apparaît que si la route a mis
|
|
un `game` dans le contexte du template (déjà fait par toutes les
|
|
pages d'un jeu) — absente sur l'accueil (liste des jeux). "Jouer" est
|
|
délibérément le DERNIER lien — une action à part (ouvre l'aperçu
|
|
jouable dans un nouvel onglet), pas une page de gestion de plus. -->
|
|
<nav class="gameNavBar" aria-label="Navigation du jeu">
|
|
<!-- Onboarding guidé (voir routes/onboarding/onboarding_new.py) : même
|
|
route pour tous les comptes — routes/games/game_dashboard.py y rend
|
|
une version simplifiée (juste les écrans) pour un type "restreint"
|
|
(quiz/embranchement/rpg), la version complète pour "custom". -->
|
|
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrans du jeu">Mes écrans</a>
|
|
<!-- "Jouer"/"Exporter" groupés à droite (gameNavRight, margin-left:auto
|
|
en CSS) et distingués par COULEUR (orange/vert, voir .gameNavLink--
|
|
play/--export dans static/style.css) — demande explicite, ce sont
|
|
des ACTIONS (lancer une partie, produire un paquet), pas des pages
|
|
de gestion comme "Mes écrans" à gauche. -->
|
|
<div class="gameNavRight">
|
|
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink gameNavLink--play" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">Jouer</a>
|
|
<!-- Phase 1.2 (feuille de route produit) : seul export retenu — paquet
|
|
STATIQUE (aucun Python/Flask embarqué, voir
|
|
publish/build_scorm_package.py), pensé pour un import dans le LMS
|
|
du client (Moodle...). L'export exécutable Windows ("Publier",
|
|
publish/build_package.py) et l'export en ligne (route publique
|
|
/jouer/<slug>, routes/public_play/) ont été retirés, jugés
|
|
redondants avec celui-ci. -->
|
|
<button type="button" class="gameNavLink gameNavLink--export" onclick="window.forgeOpenScormModal && window.forgeOpenScormModal()" title="Exporte le jeu en paquet Web/SCORM importable dans un LMS">Exporter (Web/SCORM)</button>
|
|
</div>
|
|
</nav>
|
|
{% endif %}
|
|
</div>
|
|
<main class="content {% block content_class %}{% endblock %}">
|
|
{% set recovery_codes = pop_recovery_codes() %}
|
|
{% if recovery_codes %}
|
|
<!-- À L'INTÉRIEUR de <main> (et pas à côté de #pageChrome) : pjax.js ne
|
|
remplace jamais que #pageChrome et main.content d'une navigation à
|
|
l'autre (voir swapDocument() dans pjax.js) — posé ailleurs, ce modal
|
|
serait bien présent dans le HTML renvoyé par le serveur juste après
|
|
l'inscription, mais jamais copié dans le DOM réellement affiché
|
|
puisque la redirection qui suit la confirmation de la 2FA passe par
|
|
une navigation pjax (fetch), pas un vrai rechargement de page. -->
|
|
<div class="modal is-active" id="recoveryCodesModal">
|
|
<div class="modal-background"></div>
|
|
<div class="modal-content">
|
|
<div class="box">
|
|
<h2 class="title is-4">Tes codes de récupération</h2>
|
|
<p class="hint mb-4">Note ces 10 codes en sécurité (gestionnaire de mots de passe, papier...) : chacun ne fonctionne qu'une seule fois et permet de te connecter si tu perds l'accès à ton application d'authentification. Ils ne seront plus jamais affichés.</p>
|
|
<div class="recoveryCodesGrid">
|
|
{% for code in recovery_codes %}
|
|
<code class="recoveryCode">{{ code }}</code>
|
|
{% endfor %}
|
|
</div>
|
|
<button type="button" class="button primary is-fullwidth mt-4" onclick="document.getElementById('recoveryCodesModal').remove()">J'ai noté mes codes</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% if game is defined and game %}
|
|
<!-- Même remarque que pour le modal des codes de récupération ci-dessus : à L'INTÉRIEUR
|
|
de <main> pour que pjax.js le remplace (et rejoue son script JS, voir
|
|
runScriptsIn()) à chaque navigation entre pages d'un même jeu — posé
|
|
dans #pageChrome (comme le bouton "🎓 Exporter (Web/SCORM)" qui
|
|
l'ouvre), son script ne serait rejoué qu'au tout premier chargement
|
|
complet de la page. -->
|
|
<style>
|
|
@keyframes forgePublishPulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
|
</style>
|
|
<div class="modal" id="scormModal">
|
|
<div class="modal-background"></div>
|
|
<div class="modal-content">
|
|
<div class="box">
|
|
<h2 class="title is-4">Exporter « {{ game.name }} » en Web/SCORM</h2>
|
|
<p class="hint mb-4">Construit un paquet SCORM (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>
|
|
<!-- Version SCORM (voir db/games/get_scorm_version.py) : 1.2 par
|
|
défaut (compatibilité la plus large), 2004 pour séparer
|
|
complétion et réussite (un "échoué" reste "completed" au lieu
|
|
de retomber en "incomplete", limite propre à la 1.2). -->
|
|
<div class="field mb-4">
|
|
<label class="label is-small" for="scormVersionSelect">Version SCORM</label>
|
|
<div class="control">
|
|
<div class="select is-small">
|
|
<select id="scormVersionSelect" onchange="window.forgeSaveScormVersion()">
|
|
<option value="1.2">1.2 (compatibilité la plus large)</option>
|
|
<option value="2004">2004 (3rd/4th edition — sépare complétion et réussite)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<span class="hint" id="scormVersionSaveStatus"></span>
|
|
</div>
|
|
<!-- 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" for="xapiEndpoint">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" for="xapiLogin">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" for="xapiPassword">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>
|
|
<!-- Seuil de réussite (voir db/games/get_success_threshold.py) :
|
|
s'applique même sans xAPI configuré, c'est ce qui détermine si
|
|
le SCORM classique (cmi.core.lesson_status) reporte "passed"
|
|
ou "failed" plutôt que juste "completed" — indépendant du bloc
|
|
xAPI ci-dessus, jamais retapé à chaque export non plus. -->
|
|
<details class="propGroup mb-4" id="scoringSettingsGroup">
|
|
<summary>Seuil de réussite</summary>
|
|
<div class="propGroupBody">
|
|
<p class="help mb-2">% de bonnes réponses au quiz requis pour que la partie soit reportée "Réussie" plutôt que "Échouée" (SCORM et xAPI). Sans question posée, la partie reste juste "Terminée".</p>
|
|
<div class="field">
|
|
<label class="label is-small" for="successThreshold">Seuil (%)</label>
|
|
<div class="control"><input class="input is-small" type="number" min="0" max="100" step="1" id="successThreshold"></div>
|
|
</div>
|
|
<div class="field is-grouped" style="align-items:center;">
|
|
<div class="control"><button type="button" class="button is-small" onclick="window.forgeSaveScoringSettings()">Enregistrer</button></div>
|
|
<span class="hint" id="scoringSaveStatus"></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:space-between; align-items:center; gap:8px;">
|
|
<!-- Déclaration d'accessibilité (voir publish/accessibility_declaration.py)
|
|
— pas un bouton d'export, un simple lien de téléchargement direct. -->
|
|
<a href="{{ url_for('accessibility_declaration_get', slug=game.slug) }}" class="hint" style="font-size:12px;">📄 Déclaration d'accessibilité</a>
|
|
<div style="display:flex; 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>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
var modal = document.getElementById('scormModal');
|
|
var stepText = document.getElementById('scormStepText');
|
|
var progressBox = document.getElementById('scormProgress');
|
|
var errorBox = document.getElementById('scormError');
|
|
var doneBox = document.getElementById('scormDone');
|
|
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');
|
|
|
|
var successThreshold = document.getElementById('successThreshold');
|
|
var scoringSaveStatus = document.getElementById('scoringSaveStatus');
|
|
|
|
var scormVersionSelect = document.getElementById('scormVersionSelect');
|
|
var scormVersionSaveStatus = document.getElementById('scormVersionSaveStatus');
|
|
|
|
function forgeLoadScormVersion() {
|
|
fetch("{{ url_for('scorm_version_get', slug=game.slug) }}")
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (json) { scormVersionSelect.value = json.scorm_version; });
|
|
}
|
|
|
|
window.forgeSaveScormVersion = function () {
|
|
scormVersionSaveStatus.textContent = 'Enregistrement...';
|
|
fetch("{{ url_for('scorm_version_set', slug=game.slug) }}", {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ scorm_version: scormVersionSelect.value }),
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (json) { scormVersionSaveStatus.textContent = json.ok ? 'Enregistré ✓' : 'Échec de l\'enregistrement.'; })
|
|
.catch(function () { scormVersionSaveStatus.textContent = 'Échec de l\'enregistrement.'; });
|
|
};
|
|
|
|
function forgeLoadScoringSettings() {
|
|
fetch("{{ url_for('scoring_settings_get', slug=game.slug) }}")
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (json) { successThreshold.value = json.success_threshold_percent; });
|
|
}
|
|
|
|
window.forgeSaveScoringSettings = function () {
|
|
scoringSaveStatus.textContent = 'Enregistrement...';
|
|
fetch("{{ url_for('scoring_settings_set', slug=game.slug) }}", {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ success_threshold_percent: parseInt(successThreshold.value, 10) }),
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (json) {
|
|
scoringSaveStatus.textContent = json.ok ? 'Enregistré ✓' : 'Échec de l\'enregistrement.';
|
|
if (json.ok) forgeLoadScoringSettings();
|
|
})
|
|
.catch(function () { scoringSaveStatus.textContent = 'Échec de l\'enregistrement.'; });
|
|
};
|
|
|
|
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 = '';
|
|
scoringSaveStatus.textContent = '';
|
|
scormVersionSaveStatus.textContent = '';
|
|
forgeLoadXapiSettings();
|
|
forgeLoadScoringSettings();
|
|
forgeLoadScormVersion();
|
|
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';
|
|
|
|
stepTimer = setInterval(function () {
|
|
stepIndex = (stepIndex + 1) % steps.length;
|
|
stepText.textContent = steps[stepIndex];
|
|
}, 1200);
|
|
|
|
var filename = "{{ game.slug }}-scorm.zip";
|
|
fetch("{{ url_for('export_scorm', slug=game.slug) }}", { method: 'POST' })
|
|
.then(function (resp) {
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
var disposition = resp.headers.get('Content-Disposition') || '';
|
|
var match = /filename="?([^";]+)"?/.exec(disposition);
|
|
if (match) filename = match[1];
|
|
return resp.blob();
|
|
})
|
|
.then(function (blob) {
|
|
clearInterval(stepTimer);
|
|
progressBox.style.display = 'none';
|
|
doneBox.style.display = '';
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
|
|
})
|
|
.catch(function (err) {
|
|
clearInterval(stepTimer);
|
|
progressBox.style.display = 'none';
|
|
errorBox.style.display = '';
|
|
errorBox.textContent = "Échec de l'export (" + err.message + "). Réessaie.";
|
|
});
|
|
};
|
|
|
|
window.forgeCloseScormModal = function () {
|
|
modal.classList.remove('is-active');
|
|
if (stepTimer) clearInterval(stepTimer);
|
|
};
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
<script src="{{ url_for('static', filename='pjax.js') }}"></script>
|
|
</body>
|
|
</html>
|