Files
williamandClaude Sonnet 5 c57420c8c9 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>
2026-09-15 16:06:15 +02:00

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>