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:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user