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:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+64 -54
View File
@@ -1,5 +1,6 @@
import json
import uuid
from typing import Any, Callable
from db.dialogue_lines import sanitize_dialogue_lines
from screens.flow.constants import CONDITION_OPERATORS
@@ -110,8 +111,16 @@ from screens.labels.video_modes import VIDEO_MODE_LABELS
# ("attaque"/"evenement" retirés — ne servaient à rien pour l'instant,
# demande explicite.)
ACTION_TYPES = [
"dialogue", "variable", "surbrillance", "visibilite", "son", "video", "indication", "attendre",
"interagir", "condition",
"dialogue",
"variable",
"surbrillance",
"visibilite",
"son",
"video",
"indication",
"attendre",
"interagir",
"condition",
]
TRIGGER_TYPES = ["collision", "clic", "survol"]
CONDITION_OPERATOR_KEYS = {key for key, _label in CONDITION_OPERATORS}
@@ -139,7 +148,7 @@ _MAX_ACTION_ID_LENGTH = 40
_NO_VALUE_OPERATIONS = ("definir_bool_vrai", "definir_bool_faux", "basculer_bool")
def _sanitize_dialogue(raw_dialogue):
def _sanitize_dialogue(raw_dialogue: Any) -> dict[str, Any] | None:
if not isinstance(raw_dialogue, dict):
return None
dialogue_id = raw_dialogue.get("id")
@@ -151,8 +160,8 @@ def _sanitize_dialogue(raw_dialogue):
}
def _sanitize_dialogue_action(raw_action):
action = {"type": "dialogue"}
def _sanitize_dialogue_action(raw_action: dict[str, Any]) -> dict[str, Any]:
action: dict[str, Any] = {"type": "dialogue"}
dialogue = _sanitize_dialogue(raw_action.get("dialogue"))
if dialogue:
action["dialogue"] = dialogue
@@ -161,14 +170,18 @@ def _sanitize_dialogue_action(raw_action):
return action
def _sanitize_variable_action(raw_action):
def _sanitize_variable_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
target_variable = raw_action.get("target_variable")
if not isinstance(target_variable, str) or not target_variable.strip():
return None
data_operation = raw_action.get("data_operation")
if data_operation not in DATA_OPERATION_LABELS:
return None
action = {"type": "variable", "target_variable": target_variable.strip(), "data_operation": data_operation}
action: dict[str, Any] = {
"type": "variable",
"target_variable": target_variable.strip(),
"data_operation": data_operation,
}
action_id = raw_action.get("id")
if isinstance(action_id, str) and action_id.strip():
action["id"] = action_id.strip()[:_MAX_ACTION_ID_LENGTH]
@@ -181,15 +194,17 @@ def _sanitize_variable_action(raw_action):
return action
def _sanitize_object_id(raw_action):
def _sanitize_object_id(raw_action: dict[str, Any]) -> int | None:
object_id = raw_action.get("object_id")
if object_id is None:
return None
try:
return int(object_id)
except (TypeError, ValueError):
return None
def _with_optional_id(action, raw_action):
def _with_optional_id(action: dict[str, Any], raw_action: dict[str, Any]) -> dict[str, Any]:
"""Ajoute le champ `id` (optionnel, généré côté client) commun à
toutes les feuilles autres que "dialogue" — sert à adresser CETTE
feuille précise pour y attacher un "then" plus tard (voir
@@ -200,7 +215,7 @@ def _with_optional_id(action, raw_action):
return action
def _sanitize_surbrillance_action(raw_action):
def _sanitize_surbrillance_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
object_id = _sanitize_object_id(raw_action)
valeur = raw_action.get("valeur")
if object_id is None or valeur not in SURBRILLANCE_LABELS:
@@ -208,7 +223,7 @@ def _sanitize_surbrillance_action(raw_action):
return _with_optional_id({"type": "surbrillance", "object_id": object_id, "valeur": valeur}, raw_action)
def _sanitize_visibilite_action(raw_action):
def _sanitize_visibilite_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
object_id = _sanitize_object_id(raw_action)
valeur = raw_action.get("valeur")
if object_id is None or valeur not in ELEMENT_VISIBILITY_LABELS:
@@ -216,14 +231,14 @@ def _sanitize_visibilite_action(raw_action):
return _with_optional_id({"type": "visibilite", "object_id": object_id, "valeur": valeur}, raw_action)
def _sanitize_son_action(raw_action):
def _sanitize_son_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
asset_url = raw_action.get("asset_url")
if not isinstance(asset_url, str) or not asset_url.strip():
return None
return _with_optional_id({"type": "son", "asset_url": asset_url.strip()}, raw_action)
def _sanitize_video_action(raw_action):
def _sanitize_video_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
asset_url = raw_action.get("asset_url")
mode = raw_action.get("mode")
if not isinstance(asset_url, str) or not asset_url.strip() or mode not in VIDEO_MODE_LABELS:
@@ -231,26 +246,28 @@ def _sanitize_video_action(raw_action):
return _with_optional_id({"type": "video", "asset_url": asset_url.strip(), "mode": mode}, raw_action)
def _sanitize_indication_action(raw_action):
def _sanitize_indication_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
object_id = _sanitize_object_id(raw_action)
texte = raw_action.get("texte")
if object_id is None or not isinstance(texte, str) or not texte.strip():
return None
action = {"type": "indication", "object_id": object_id, "texte": texte.strip()[:200]}
action: dict[str, Any] = {"type": "indication", "object_id": object_id, "texte": texte.strip()[:200]}
duree_ms = raw_action.get("duree_ms")
if isinstance(duree_ms, (int, float)) and not isinstance(duree_ms, bool) and duree_ms > 0:
action["duree_ms"] = int(duree_ms)
return _with_optional_id(action, raw_action)
def _sanitize_attendre_action(raw_action):
""""Attendre N secondes" avant de continuer la chaîne — MÊME
def _sanitize_attendre_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
""" "Attendre N secondes" avant de continuer la chaîne — MÊME
vocabulaire que l'action "attendre" du Flow (voir static/js/play/
actions.js, `data_value` en SECONDES, pas en ms) : suspend le "then"
plutôt que de l'exécuter immédiatement (comme "video"), utile par ex.
pour une narration à l'affichage de l'écran qui doit laisser un temps
de lecture avant d'enchaîner."""
raw_value = raw_action.get("data_value")
if raw_value is None:
return None
try:
seconds = float(raw_value)
except (TypeError, ValueError):
@@ -263,7 +280,7 @@ def _sanitize_attendre_action(raw_action):
# Une seule table de vérité pour les feuilles : ajouter un type ici suffit
# (voir LEAF_ACTION_TYPES dérivé de ses clés) — évite le if/elif qui aurait
# dû être répété à chaque nouvel ajout.
_LEAF_ACTION_SANITIZERS = {
_LEAF_ACTION_SANITIZERS: dict[str, Callable[[dict[str, Any]], dict[str, Any] | None]] = {
"dialogue": _sanitize_dialogue_action,
"variable": _sanitize_variable_action,
"surbrillance": _sanitize_surbrillance_action,
@@ -276,14 +293,14 @@ _LEAF_ACTION_SANITIZERS = {
LEAF_ACTION_TYPES = tuple(_LEAF_ACTION_SANITIZERS)
def _sanitize_leaf_action(raw_action, depth=1):
def _sanitize_leaf_action(raw_action: Any, depth: int = 1) -> dict[str, Any] | None:
"""Sanitize une FEUILLE (voir LEAF_ACTION_TYPES) et son éventuelle
chaîne "then" (bornée à _MAX_THEN_CHAIN) — utilisée partout où une
feuille est attendue : action de premier niveau, sous "interagir", ou
branche Vrai/Faux de "condition"."""
if not isinstance(raw_action, dict):
return None
sanitizer = _LEAF_ACTION_SANITIZERS.get(raw_action.get("type"))
sanitizer = _LEAF_ACTION_SANITIZERS.get(raw_action.get("type") or "")
if not sanitizer:
return None
action = sanitizer(raw_action)
@@ -296,7 +313,7 @@ def _sanitize_leaf_action(raw_action, depth=1):
return action
def _sanitize_condition_branch(raw_branch):
def _sanitize_condition_branch(raw_branch: Any) -> dict[str, Any] | None:
"""Une branche Vrai/Faux de "condition" : soit None ("ne rien
faire", jamais un dict vide), soit une FEUILLE ("dialogue" ou
"variable", éventuellement chaînée via "then") — jamais "interagir"/
@@ -307,7 +324,7 @@ def _sanitize_condition_branch(raw_branch):
return _sanitize_leaf_action(raw_branch)
def _sanitize_action(raw_action, depth=1):
def _sanitize_action(raw_action: Any, depth: int = 1) -> dict[str, Any] | None:
if not isinstance(raw_action, dict):
return None
action_type = raw_action.get("type")
@@ -337,7 +354,7 @@ def _sanitize_action(raw_action, depth=1):
}
def sanitize_collision_rules(raw_rules, allowed_triggers=None):
def sanitize_collision_rules(raw_rules: Any, allowed_triggers: list[str] | None = None) -> list[dict[str, Any]]:
"""Valide/nettoie une liste de règles postée par le client (voir
routes/triggers/) — une règle invalide (déclencheur/action inconnus,
imbrication trop profonde...) est IGNORÉE plutôt que de faire planter
@@ -350,7 +367,7 @@ def sanitize_collision_rules(raw_rules, allowed_triggers=None):
allowed_triggers = TRIGGER_TYPES
if not isinstance(raw_rules, list):
return []
cleaned = []
cleaned: list[dict[str, Any]] = []
for raw_rule in raw_rules[:50]: # marge large, jamais illimité
if not isinstance(raw_rule, dict):
continue
@@ -364,7 +381,7 @@ def sanitize_collision_rules(raw_rules, allowed_triggers=None):
return cleaned
def _apply_dialogue_lines(action, dialogue_id, lines):
def _apply_dialogue_lines(action: Any, dialogue_id: str, lines: list[dict[str, Any]]) -> bool:
if not isinstance(action, dict):
return False
action_type = action.get("type")
@@ -379,14 +396,13 @@ def _apply_dialogue_lines(action, dialogue_id, lines):
if action_type == "interagir":
return _apply_dialogue_lines(action.get("sub_action"), dialogue_id, lines)
if action_type == "condition":
return (
_apply_dialogue_lines(action.get("si_vrai"), dialogue_id, lines)
or _apply_dialogue_lines(action.get("si_faux"), dialogue_id, lines)
return _apply_dialogue_lines(action.get("si_vrai"), dialogue_id, lines) or _apply_dialogue_lines(
action.get("si_faux"), dialogue_id, lines
)
return False
def set_dialogue_lines_in_rules(rules, dialogue_id, raw_lines):
def set_dialogue_lines_in_rules(rules: list[dict[str, Any]], dialogue_id: str, raw_lines: Any) -> bool:
"""Met à jour EN PLACE les répliques du dialogue `dialogue_id`,
où qu'il se trouve dans l'arbre d'action d'une des règles (direct,
chaîné via "then", sous "interagir", ou dans une branche de
@@ -395,13 +411,10 @@ def set_dialogue_lines_in_rules(rules, dialogue_id, raw_lines):
rien modifier si `dialogue_id` n'existe dans aucune règle (référence
orpheline, ex. règle supprimée entre-temps)."""
lines = sanitize_dialogue_lines(raw_lines)
for rule in rules:
if _apply_dialogue_lines(rule.get("action"), dialogue_id, lines):
return True
return False
return any(_apply_dialogue_lines(rule.get("action"), dialogue_id, lines) for rule in rules)
def _leaf_id(action):
def _leaf_id(action: dict[str, Any]) -> str | None:
if action.get("type") == "dialogue":
dialogue = action.get("dialogue")
return dialogue.get("id") if isinstance(dialogue, dict) else None
@@ -410,7 +423,7 @@ def _leaf_id(action):
return None
def _apply_then_action(action, leaf_id, then_action):
def _apply_then_action(action: Any, leaf_id: str, then_action: dict[str, Any]) -> bool:
if not isinstance(action, dict):
return False
action_type = action.get("type")
@@ -431,14 +444,13 @@ def _apply_then_action(action, leaf_id, then_action):
if action_type == "interagir":
return _apply_then_action(action.get("sub_action"), leaf_id, then_action)
if action_type == "condition":
return (
_apply_then_action(action.get("si_vrai"), leaf_id, then_action)
or _apply_then_action(action.get("si_faux"), leaf_id, then_action)
return _apply_then_action(action.get("si_vrai"), leaf_id, then_action) or _apply_then_action(
action.get("si_faux"), leaf_id, then_action
)
return False
def set_then_action_in_rules(rules, leaf_id, raw_then_action):
def set_then_action_in_rules(rules: list[dict[str, Any]], leaf_id: str, raw_then_action: Any) -> bool:
"""INSÈRE EN PLACE une NOUVELLE action juste après la feuille
identifiée par `leaf_id` (dialogue.id pour une feuille dialogue, id
pour toute autre feuille) — n'importe où dans l'arbre d'action d'une
@@ -452,10 +464,7 @@ def set_then_action_in_rules(rules, leaf_id, raw_then_action):
then_action = _sanitize_leaf_action(raw_then_action)
if not then_action:
return False
for rule in rules:
if _apply_then_action(rule.get("action"), leaf_id, then_action):
return True
return False
return any(_apply_then_action(rule.get("action"), leaf_id, then_action) for rule in rules)
# ---------- Suppression/réordonnancement d'un maillon précis (demande
@@ -468,7 +477,8 @@ def set_then_action_in_rules(rules, leaf_id, raw_then_action):
# RÈGLE elle-même doit être remplacée ou retirée, pas juste un champ
# interne).
def _remove_leaf_id(action, leaf_id):
def _remove_leaf_id(action: Any, leaf_id: str) -> tuple[Any, bool]:
"""Retire la feuille `leaf_id` de l'arbre `action` — renvoie
(nouvel_arbre_ou_None, trouvé). `nouvel_arbre` vaut None si `action`
lui-même était la cible et n'avait rien pour le remplacer (ni "then",
@@ -511,7 +521,7 @@ def _remove_leaf_id(action, leaf_id):
return action, False
def remove_action_from_rules(rules, leaf_id):
def remove_action_from_rules(rules: list[dict[str, Any]], leaf_id: str) -> bool:
"""Retire EN PLACE l'action `leaf_id` n'importe où dans l'arbre d'une
des règles (feuille directe d'une règle, maillon d'une chaîne "then",
sous-action d'un "interagir", ou dans une branche de "condition"). Si
@@ -531,12 +541,12 @@ def remove_action_from_rules(rules, leaf_id):
return False
def _chain_as_list(head_action):
def _chain_as_list(head_action: Any) -> list[dict[str, Any]]:
"""Aplatit une chaîne de FEUILLES pures (voir LEAF_ACTION_TYPES) en
liste — le réordonnancement ne s'applique qu'à l'intérieur d'une
chaîne "then" (jamais entre deux branches différentes d'une
condition, ni entre une "interagir" et ce qui l'entoure)."""
items = []
items: list[dict[str, Any]] = []
node = head_action
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
items.append(node)
@@ -544,7 +554,7 @@ def _chain_as_list(head_action):
return items
def _relink_chain(items):
def _relink_chain(items: list[dict[str, Any]]) -> dict[str, Any] | None:
items = [dict(n) for n in items]
for i in range(len(items) - 1):
items[i]["then"] = items[i + 1]
@@ -553,7 +563,7 @@ def _relink_chain(items):
return items[0] if items else None
def _move_in_action_tree(action, leaf_id, direction):
def _move_in_action_tree(action: Any, leaf_id: str, direction: str) -> dict[str, Any] | None:
"""Cherche, dans `action`, LA chaîne "then" qui contient `leaf_id`
(au premier niveau, sous une "interagir", ou dans une branche de
"condition"), et échange `leaf_id` avec son voisin immédiat
@@ -593,7 +603,7 @@ def _move_in_action_tree(action, leaf_id, direction):
return None
def move_action_in_rules(rules, leaf_id, direction):
def move_action_in_rules(rules: list[dict[str, Any]], leaf_id: str, direction: str) -> bool:
"""Déplace EN PLACE la feuille `leaf_id` d'un cran vers le haut
("up") ou le bas ("down") DANS SA PROPRE CHAÎNE "then" (jamais
au-delà de sa branche de condition/sous-action interagir — voir
@@ -611,11 +621,11 @@ def move_action_in_rules(rules, leaf_id, direction):
return False
def _generate_leaf_id():
def _generate_leaf_id() -> str:
return "d_" + uuid.uuid4().hex[:12]
def _backfill_leaf_ids(action):
def _backfill_leaf_ids(action: Any) -> bool:
"""Assure EN PLACE qu'un id STABLE existe sur toute feuille non-
"dialogue" (qui a déjà le sien via dialogue.id) qui n'en avait pas —
ex. une action "variable" posée AVANT que ce champ ne devienne
@@ -647,7 +657,7 @@ def _backfill_leaf_ids(action):
return changed
def backfill_missing_leaf_ids(rules):
def backfill_missing_leaf_ids(rules: list[dict[str, Any]]) -> bool:
"""Applique _backfill_leaf_ids à CHAQUE règle de `rules` (mutées EN
PLACE) — renvoie True si au moins une règle a été modifiée, auquel
cas l'appelant DOIT persister `rules` (voir routes/triggers/
@@ -663,7 +673,7 @@ def backfill_missing_leaf_ids(rules):
return changed
def resolve_collision_rules(obj):
def resolve_collision_rules(obj: dict[str, Any]) -> list[dict[str, Any]]:
"""Décode attributes._collision_rules en liste Python, jamais None —
un attribut absent/corrompu retombe sur aucune règle (comportement
inchangé pour tout objet déjà posé avant l'ajout de cet éditeur)."""