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
+105 -70
View File
@@ -20,88 +20,123 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un
vrai INSERT dans cette table.
"""
from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
from .slugify import slugify
from .table_name_for import table_name_for
from .game_dir import game_dir
from .db_path import db_path
from .connection import connect
from .games.list_games import list_games
from .games.game_meta import game_meta
from .games.get_game_type import get_game_type, DEFAULT_GAME_TYPE
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.get_success_threshold import get_success_threshold
from .games.set_success_threshold import set_success_threshold
from .games.get_scorm_version import get_scorm_version
from .games.set_scorm_version import set_scorm_version
from .games.game_type_catalog import (
ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE,
get_onboarding_type, get_onboarding_type_raw, set_onboarding_type,
is_restricted,
)
from .definitions.list_definitions import list_definitions
from .definitions.get_definition import get_definition
from .constants import FIELD_TYPES, GLOBAL_VARIABLE_TYPES, PROJECTS_DIR, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
from .custom_events.create_custom_event import create_custom_event
from .custom_events.get_custom_event import get_custom_event
from .custom_events.list_custom_events import list_custom_events
from .custom_events.update_custom_event import update_custom_event
from .db_path import db_path
from .definitions.create_definition import create_definition
from .definitions.rename_definition import rename_definition
from .definitions.definitions_referencing import definitions_referencing
from .definitions.add_field_to_definition import add_field_to_definition
from .definitions.update_field import update_field
from .definitions.delete_field import delete_field
from .definitions.delete_definition import delete_definition
from .rows.list_rows import list_rows
from .rows.relation_options import relation_options
from .rows.insert_row import insert_row
from .rows.get_row import get_row
from .rows.update_row import update_row
from .rows.update_row_field import update_row_field
from .rows.delete_row import delete_row
from .rows.rows_referencing import rows_referencing
from .global_vars.ensure_global_vars_schema import PLAYER_SHARED
from .global_vars.list_global_variables import list_global_variables
from .global_vars.list_global_variables_for_player import list_global_variables_for_player
from .global_vars.get_global_variable import get_global_variable
from .definitions.get_definition import get_definition
from .definitions.list_definitions import list_definitions
from .dialogue_lines import QUESTION_REWARD_TYPES, sanitize_dialogue_lines, sum_question_rewards
from .game_dir import game_dir
from .games.create_game import create_game
from .games.delete_game import delete_game
from .games.game_meta import game_meta
from .games.game_type_catalog import (
DEFAULT_ONBOARDING_TYPE,
ONBOARDING_TYPES,
get_onboarding_type,
get_onboarding_type_raw,
is_restricted,
set_onboarding_type,
)
from .games.get_game_type import DEFAULT_GAME_TYPE, get_game_type
from .games.get_scorm_version import get_scorm_version
from .games.get_success_threshold import get_success_threshold
from .games.get_xapi_settings import get_xapi_settings
from .games.list_games import list_games
from .games.move_game import move_game
from .games.set_scorm_version import set_scorm_version
from .games.set_success_threshold import set_success_threshold
from .games.set_xapi_settings import set_xapi_settings
from .games.update_game_name import update_game_name
from .global_vars.create_global_variable import create_global_variable
from .global_vars.update_global_variable_value import update_global_variable_value
from .global_vars.update_global_variable import update_global_variable
from .global_vars.delete_global_variable import delete_global_variable
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
from .global_vars.ensure_global_vars_schema import PLAYER_SHARED
from .global_vars.get_global_variable import get_global_variable
from .global_vars.list_global_variables import list_global_variables
from .global_vars.list_global_variables_for_player import list_global_variables_for_player
from .global_vars.update_global_variable import update_global_variable
from .global_vars.update_global_variable_value import update_global_variable_value
from .json_for_script import json_for_script
from .rows.delete_row import delete_row
from .rows.get_row import get_row
from .rows.insert_row import insert_row
from .rows.list_rows import list_rows
from .rows.update_row import update_row
from .rows.update_row_field import update_row_field
from .scoring.get_score import get_score
from .scoring.set_score_value import set_score_value
from .scoring.set_status import set_status
from .custom_events.list_custom_events import list_custom_events
from .custom_events.get_custom_event import get_custom_event
from .custom_events.create_custom_event import create_custom_event
from .custom_events.update_custom_event import update_custom_event
from .dialogue_lines import sanitize_dialogue_lines, QUESTION_REWARD_TYPES, sum_question_rewards
from .slugify import slugify
from .table_name_for import table_name_for
__all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
"SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status",
"slugify", "table_name_for", "game_dir", "db_path", "connect",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
"get_game_type", "DEFAULT_GAME_TYPE",
"ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type",
"get_onboarding_type_raw", "set_onboarding_type", "is_restricted",
"list_definitions", "get_definition", "create_definition", "rename_definition",
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
"PROJECTS_DIR",
"FIELD_TYPES",
"GLOBAL_VARIABLE_TYPES",
"PLAYER_SHARED",
"SCORE_STATUS_CHOICES",
"SCORE_STATUS_LABELS",
"get_score",
"set_score_value",
"set_status",
"slugify",
"table_name_for",
"json_for_script",
"game_dir",
"db_path",
"connect",
"list_games",
"game_meta",
"create_game",
"update_game_name",
"delete_game",
"move_game",
"get_game_type",
"DEFAULT_GAME_TYPE",
"get_xapi_settings",
"set_xapi_settings",
"get_success_threshold",
"set_success_threshold",
"get_scorm_version",
"set_scorm_version",
"ONBOARDING_TYPES",
"DEFAULT_ONBOARDING_TYPE",
"get_onboarding_type",
"get_onboarding_type_raw",
"set_onboarding_type",
"is_restricted",
"list_definitions",
"get_definition",
"create_definition",
"definitions_referencing",
"delete_definition",
"list_rows", "relation_options", "insert_row", "get_row", "update_row",
"update_row_field", "delete_row", "rows_referencing",
"list_global_variables", "list_global_variables_for_player", "get_global_variable", "create_global_variable",
"update_global_variable_value", "update_global_variable", "delete_global_variable",
"list_rows",
"insert_row",
"get_row",
"update_row",
"update_row_field",
"delete_row",
"list_global_variables",
"list_global_variables_for_player",
"get_global_variable",
"create_global_variable",
"update_global_variable_value",
"update_global_variable",
"delete_global_variable",
"delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
"sanitize_dialogue_lines", "QUESTION_REWARD_TYPES", "sum_question_rewards",
"list_custom_events",
"get_custom_event",
"create_custom_event",
"update_custom_event",
"sanitize_dialogue_lines",
"QUESTION_REWARD_TYPES",
"sum_question_rewards",
]
+17 -17
View File
@@ -1,9 +1,14 @@
import contextlib
import sqlite3
from typing import TYPE_CHECKING
from .db_path import db_path
if TYPE_CHECKING:
from flask import Flask
def connect(slug):
def connect(slug: str) -> sqlite3.Connection:
# timeout=10 : si une autre connexion tient un verrou d'écriture au même
# instant (deux requêtes qui arrivent presque en même temps, ex. deux
# onglets, ou le navigateur qui recharge plusieurs ressources), sqlite3
@@ -21,7 +26,7 @@ def connect(slug):
return conn
def _track_for_teardown(conn):
def _track_for_teardown(conn: sqlite3.Connection) -> None:
"""Filet de sécurité : chaque fonction de db/ ouvre sa propre connexion
et est censée la fermer elle-même (conn.close()) avant de rendre la
main — mais si une exception survient ENTRE l'ouverture et cette
@@ -50,23 +55,18 @@ def _track_for_teardown(conn):
g._forge_db_connections.append(conn)
def _install_teardown_safety_net():
"""Appelé une seule fois (voir le bas de ce fichier) — enregistre le
filet de sécurité sur l'appli Flask. `core.flask_app` ne dépend de rien
dans `db/`, donc cet import ne crée pas de dépendance circulaire."""
try:
from core.flask_app import app
except ImportError:
return
def install_teardown_safety_net(app: "Flask") -> None:
"""Enregistre le filet de sécurité (voir _track_for_teardown) sur
l'appli Flask passée en paramètre — jamais importée ici : `db/` est la
couche la plus basse du moteur (voir pyproject.toml, contrat
import-linter) et ne doit dépendre d'aucun autre paquet. C'est
core/db_teardown_guard.py, dans la couche de câblage, qui appelle
cette fonction avec `core.flask_app.app`."""
@app.teardown_request
def _close_leaked_connections(exception=None): # noqa: ARG001 - signature imposée par Flask
def _close_leaked_connections(exception: BaseException | None = None) -> None: # noqa: ARG001 - signature imposée par Flask
from flask import g
for conn in getattr(g, "_forge_db_connections", ()):
try:
with contextlib.suppress(sqlite3.Error):
conn.close()
except sqlite3.Error:
pass
_install_teardown_safety_net()
+3 -3
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def create_custom_event(slug, name, description=""):
def create_custom_event(slug: str, name: str, description: str = "") -> int | None:
"""Idempotent par nom (même convention que create_global_variable.py) :
si le nom existe déjà, ne touche à rien et renvoie simplement son id
existant plutôt que de lever une erreur — sans risque en cas de
@@ -15,12 +15,12 @@ def create_custom_event(slug, name, description=""):
existing = conn.execute("SELECT id FROM _custom_events WHERE name = ?", (name,)).fetchone()
if existing:
conn.close()
return existing["id"]
return int(existing["id"])
conn.execute(
"INSERT INTO _custom_events (name, description) VALUES (?, ?)",
(name, (description or "").strip()),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
new_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return new_id
@@ -1,7 +1,7 @@
from ..connection import connect
def ensure_custom_events_schema(slug):
def ensure_custom_events_schema(slug: str) -> None:
"""Migration légère (même principe que ensure_global_vars_schema.py) :
crée _custom_events si absente. Un événement personnalisé vit pour
TOUT le jeu (pas par écran, pas par modèle) : "name" est donc UNIQUE —
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def get_custom_event(slug, event_id):
def get_custom_event(slug: str, event_id: int | None) -> dict[str, Any] | None:
if not event_id:
return None
ensure_custom_events_schema(slug)
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def list_custom_events(slug):
def list_custom_events(slug: str) -> list[dict[str, Any]]:
ensure_custom_events_schema(slug)
conn = connect(slug)
rows = conn.execute("SELECT * FROM _custom_events ORDER BY name").fetchall()
+1 -1
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def update_custom_event(slug, event_id, description=""):
def update_custom_event(slug: str, event_id: int, description: str = "") -> None:
"""Le NOM reste volontairement immuable après création — comme une
variable globale (voir update_global_variable.py) : c'est par ce nom
qu'on désigne l'événement dans l'interface, mais surtout par son ID
+1 -1
View File
@@ -3,5 +3,5 @@ import os
from .game_dir import game_dir
def db_path(slug):
def db_path(slug: str) -> str:
return os.path.join(game_dir(slug), "game.db")
-44
View File
@@ -1,44 +0,0 @@
from ..connection import connect
from ..constants import FIELD_TYPES
from ..quote_ident import quote_ident
from ..slugify import slugify
from .get_definition import get_definition
def add_field_to_definition(slug, definition_id, field):
"""CRUD — Update d'une définition : ajoute un champ à un objet déjà
créé. Exécute un vrai ALTER TABLE ... ADD COLUMN sur la table SQL
existante (les lignes déjà enregistrées reçoivent NULL pour ce
nouveau champ, comportement standard d'un ALTER TABLE)."""
definition = get_definition(slug, definition_id)
conn = connect(slug)
fname = slugify(field["name"]).replace("-", "_")
ftype = field["type"]
required = 1 if field.get("required") else 0
relation_definition_id = None
position = (max((f["position"] for f in definition["fields"]), default=-1)) + 1
if ftype == "relation":
related = get_definition(slug, int(field["relation_definition_id"]))
col = f"{fname}_id"
# ALTER TABLE ADD COLUMN de SQLite n'accepte pas de contrainte
# REFERENCES portant sur une colonne ajoutée après coup avec la
# même simplicité qu'à la création : on ajoute la colonne simple —
# c'est la table _fields qui reste la source de vérité utilisée par
# le moteur pour savoir que cette colonne est une relation.
conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {quote_ident(col)} INTEGER")
relation_definition_id = related["id"]
else:
sql_type = FIELD_TYPES[ftype]["sql"]
conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {quote_ident(fname)} {sql_type}")
min_value = field.get("min_value") if ftype in ("nombre_entier", "nombre_decimal") else None
max_value = field.get("max_value") if ftype in ("nombre_entier", "nombre_decimal") else None
conn.execute(
"""INSERT INTO _fields
(definition_id, name, type, relation_definition_id, required, position, min_value, max_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(definition_id, field["name"], ftype, relation_definition_id, required, position, min_value, max_value),
)
conn.commit()
conn.close()
+11 -6
View File
@@ -1,13 +1,15 @@
from typing import Any
from ..connection import connect
from ..constants import FIELD_TYPES
from ..quote_ident import quote_ident
from ..slugify import slugify
from ..table_name_for import table_name_for
from .get_definition import get_definition
from .ensure_field_bounds_schema import ensure_field_bounds_schema
from .get_definition import get_definition
def create_definition(slug, name, fields, per_player=True):
def create_definition(slug: str, name: str, fields: list[dict[str, Any]], per_player: bool = True) -> int:
"""Feature 2 : crée une définition d'objet (comme une table de BDD) et
exécute le vrai CREATE TABLE correspondant, avec les bons types de
colonne, y compris les colonnes de clé étrangère pour les relations
@@ -30,7 +32,7 @@ def create_definition(slug, name, fields, per_player=True):
"INSERT INTO _definitions (name, table_name, per_player) VALUES (?, ?, ?)",
(name, tname, 1 if per_player else 0),
)
definition_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
definition_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
columns_sql = ["id INTEGER PRIMARY KEY AUTOINCREMENT"]
for pos, f in enumerate(fields):
@@ -41,10 +43,13 @@ def create_definition(slug, name, fields, per_player=True):
if ftype == "relation":
related = get_definition(slug, int(f["relation_definition_id"]))
if related is None:
raise ValueError(
f"relation_definition_id {f['relation_definition_id']!r} ne correspond "
"a aucune definition existante"
)
col = f"{fname}_id"
columns_sql.append(
f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(id)"
)
columns_sql.append(f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(id)")
relation_definition_id = related["id"]
else:
sql_type = FIELD_TYPES[ftype]["sql"]
+3 -1
View File
@@ -1,7 +1,9 @@
from typing import Any
from ..connection import connect
def definitions_referencing(slug, definition_id):
def definitions_referencing(slug: str, definition_id: int) -> list[dict[str, Any]]:
"""Autres définitions de CE jeu qui ont un champ de type relation
pointant vers cette définition — utilisé pour bloquer une suppression
qui casserait ces relations."""
+3 -1
View File
@@ -2,8 +2,10 @@ from ..connection import connect
from .get_definition import get_definition
def delete_definition(slug, definition_id):
def delete_definition(slug: str, definition_id: int) -> None:
definition = get_definition(slug, definition_id)
if definition is None:
raise ValueError(f"definition_id {definition_id!r} ne correspond a aucune definition existante")
conn = connect(slug)
conn.execute(f"DROP TABLE IF EXISTS {definition['table_name']}")
conn.execute("DELETE FROM _fields WHERE definition_id = ?", (definition_id,))
-29
View File
@@ -1,29 +0,0 @@
import sqlite3
from ..connection import connect
from ..quote_ident import quote_ident
from ..slugify import slugify
from .get_definition import get_definition
def delete_field(slug, definition_id, field_id):
"""CRUD — Update d'une définition : retire un champ. Exécute un vrai
ALTER TABLE ... DROP COLUMN (SQLite ≥ 3.35). Sur une version de SQLite
trop ancienne pour DROP COLUMN, le champ est retiré de la définition
(le moteur ne le proposera plus dans les formulaires) mais la colonne
SQL peut subsister sans casser quoi que ce soit d'autre."""
definition = get_definition(slug, definition_id)
field = next((f for f in definition["fields"] if f["id"] == field_id), None)
if not field:
return
col = slugify(field["name"]).replace("-", "_")
if field["type"] == "relation":
col += "_id"
conn = connect(slug)
try:
conn.execute(f"ALTER TABLE {definition['table_name']} DROP COLUMN {quote_ident(col)}")
except sqlite3.OperationalError:
pass
conn.execute("DELETE FROM _fields WHERE id = ?", (field_id,))
conn.commit()
conn.close()
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def ensure_field_bounds_schema(slug):
def ensure_field_bounds_schema(slug: str) -> None:
"""Migration légère (voir screens/screens_repo/ensure_schema.py pour le
même principe) : ajoute les colonnes min_value/max_value à _fields pour
les jeux créés avant le bornage automatique (2.2), et per_player à
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect
from .ensure_field_bounds_schema import ensure_field_bounds_schema
def get_definition(slug, definition_id):
def get_definition(slug: str, definition_id: int) -> dict[str, Any] | None:
ensure_field_bounds_schema(slug)
conn = connect(slug)
d = conn.execute("SELECT * FROM _definitions WHERE id = ?", (definition_id,)).fetchone()
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect
from .ensure_field_bounds_schema import ensure_field_bounds_schema
def list_definitions(slug):
def list_definitions(slug: str) -> list[dict[str, Any]]:
ensure_field_bounds_schema(slug)
conn = connect(slug)
rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall()
-8
View File
@@ -1,8 +0,0 @@
from ..connection import connect
def rename_definition(slug, definition_id, new_name):
conn = connect(slug)
conn.execute("UPDATE _definitions SET name = ? WHERE id = ?", (new_name, definition_id))
conn.commit()
conn.close()
-49
View File
@@ -1,49 +0,0 @@
import sqlite3
from ..connection import connect
from ..quote_ident import quote_ident
from ..slugify import slugify
from .get_definition import get_definition
def update_field(slug, definition_id, field_id, new_name, required, relation_definition_id=None, min_value=None, max_value=None):
"""CRUD — Update d'une définition : modifie un champ déjà créé (nom,
obligatoire, objet lié si c'est une relation, et bornes mini/maxi si
c'est un champ numérique — voir 2.2, bornage automatique). Si le nom
change, la vraie colonne SQL est renommée (ALTER TABLE ... RENAME
COLUMN, SQLite ≥ 3.25) pour que la colonne réelle continue de
correspondre exactement au nom du champ tel qu'affiché — pas de dérive
entre la définition et la table. Le TYPE d'un champ existant ne se
change pas ici (une vraie conversion de type SQLite demanderait de
reconstruire la table et de convertir les données déjà enregistrées,
hors scope de cette version)."""
definition = get_definition(slug, definition_id)
field = next((f for f in definition["fields"] if f["id"] == field_id), None)
if not field:
return
old_col = slugify(field["name"]).replace("-", "_")
new_col = slugify(new_name).replace("-", "_")
if field["type"] == "relation":
old_col += "_id"
new_col += "_id"
conn = connect(slug)
if old_col != new_col:
try:
conn.execute(
f"ALTER TABLE {definition['table_name']} RENAME COLUMN {quote_ident(old_col)} TO {quote_ident(new_col)}"
)
except sqlite3.OperationalError:
pass # SQLite trop ancien pour RENAME COLUMN : la colonne SQL garde son ancien nom
rel_id = int(relation_definition_id) if (field["type"] == "relation" and relation_definition_id) else field["relation_definition_id"]
is_numeric = field["type"] in ("nombre_entier", "nombre_decimal")
min_v = (min_value if min_value not in (None, "") else None) if is_numeric else None
max_v = (max_value if max_value not in (None, "") else None) if is_numeric else None
conn.execute(
"UPDATE _fields SET name = ?, required = ?, relation_definition_id = ?, min_value = ?, max_value = ? WHERE id = ?",
(new_name, 1 if required else 0, rel_id, min_v, max_v, field_id),
)
conn.commit()
conn.close()
+18 -9
View File
@@ -1,3 +1,5 @@
from typing import Any
_MAX_LINES_PER_COLUMN = 200
_MAX_SPEAKER_LENGTH = 60
@@ -12,7 +14,7 @@ _MIN_CHOICES = 2
_MAX_CHOICES = 4
def _sanitize_dialogue_line(raw_line):
def _sanitize_dialogue_line(raw_line: dict[str, Any]) -> dict[str, Any] | None:
speaker = raw_line.get("speaker")
text = raw_line.get("text")
if not isinstance(speaker, str) or not speaker.strip():
@@ -32,7 +34,7 @@ def _sanitize_dialogue_line(raw_line):
return line
def _sanitize_question_line(raw_line):
def _sanitize_question_line(raw_line: dict[str, Any]) -> dict[str, Any] | None:
text = raw_line.get("text")
if not isinstance(text, str) or not text.strip():
return None
@@ -42,8 +44,11 @@ def _sanitize_question_line(raw_line):
choices = [c.strip() for c in raw_choices if isinstance(c, str) and c.strip()][:_MAX_CHOICES]
if len(choices) < _MIN_CHOICES:
return None
raw_correct_index = raw_line.get("correct_index")
if raw_correct_index is None:
return None
try:
correct_index = int(raw_line.get("correct_index"))
correct_index = int(raw_correct_index)
except (TypeError, ValueError):
return None
if not (0 <= correct_index < len(choices)):
@@ -56,12 +61,16 @@ def _sanitize_question_line(raw_line):
except (TypeError, ValueError):
reward_amount = 0
return {
"type": "question", "text": text.strip(), "choices": choices,
"correct_index": correct_index, "reward_type": reward_type, "reward_amount": reward_amount,
"type": "question",
"text": text.strip(),
"choices": choices,
"correct_index": correct_index,
"reward_type": reward_type,
"reward_amount": reward_amount,
}
def _sanitize_line(raw_line):
def _sanitize_line(raw_line: Any) -> dict[str, Any] | None:
if not isinstance(raw_line, dict):
return None
if raw_line.get("type") == "question":
@@ -69,7 +78,7 @@ def _sanitize_line(raw_line):
return _sanitize_dialogue_line(raw_line)
def sanitize_dialogue_lines(raw_lines):
def sanitize_dialogue_lines(raw_lines: Any) -> list[dict[str, Any]]:
"""Sanitize une LISTE de répliques de dialogue ({type: "dialogue",
speaker, text} — le nom vient du champ "ℹ️ Informations" d'un objet de
scène, ou "Joueur", voir screens/rendering/scene_object_names.py)
@@ -86,14 +95,14 @@ def sanitize_dialogue_lines(raw_lines):
return [line for line in cleaned if line]
def sum_question_rewards(lines):
def sum_question_rewards(lines: Any) -> int:
"""Somme des récompenses de toutes les questions ("❓ Question" ci-
dessus) d'UNE liste de répliques/questions — utilisée pour calculer le
score max possible du quiz à l'export SCORM, sur TOUTES les lignes de
TOUS les dialogues du jeu (voir screens.collect_all_dialogue_lines,
publish/build_scorm_package.py)."""
total = 0
for line in (lines or []):
for line in lines or []:
if isinstance(line, dict) and line.get("type") == "question":
total += line.get("reward_amount", 0) or 0
return total
+1 -1
View File
@@ -4,7 +4,7 @@ from .constants import PROJECTS_DIR
from .games.project_slug import split_slug
def game_dir(slug):
def game_dir(slug: str) -> str:
# Structure de dossiers par utilisateur (voir db/games/project_slug.py)
# : un slug composé "propriétaire_projet" résout vers un vrai chemin
# imbriqué projects/<propriétaire>/<projet>/ — repli sur l'ancien
+1 -1
View File
@@ -6,7 +6,7 @@ from ..slugify import slugify
from .project_slug import build_slug
def create_game(name, owner_folder=None, project_slug_override=None):
def create_game(name: str, owner_folder: str | None = None, project_slug_override: str | None = None) -> str:
"""Feature 1 : crée le dossier du jeu, ses fichiers index.html/css/js
reliés entre eux, et sa base de données dédiée (nom du jeu en méta).
+1 -1
View File
@@ -6,7 +6,7 @@ from ..game_dir import game_dir
from .project_slug import split_slug
def delete_game(slug):
def delete_game(slug: str) -> None:
shutil.rmtree(game_dir(slug))
# Structure de dossiers par utilisateur (voir project_slug.py) :
# nettoie aussi le dossier propriétaire s'il ne contient plus aucun
+5 -2
View File
@@ -1,13 +1,16 @@
from typing import Any
from ..connection import connect
from .game_type_catalog import get_onboarding_type
def game_meta(slug):
def game_meta(slug: str) -> dict[str, Any]:
conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close()
return {
"slug": slug, "name": row["value"] if row else slug,
"slug": slug,
"name": row["value"] if row else slug,
# Onboarding guidé (voir game_type_catalog.py) : utilisé par
# templates/base.html pour cacher le lien "Tableau de bord" à un
# compte "restreint" (quiz/embranchement/rpg).
+11 -9
View File
@@ -13,11 +13,14 @@ _meta['onboarding_type'] (une ligne _meta par projet, même convention que
game_type) retient CE choix — jamais lu par le
rendu jouable, seulement par le routage (routes/games/game_dashboard.py,
routes/screens/screens_new.py)."""
from typing import Any
import db
DEFAULT_ONBOARDING_TYPE = "rpg"
ONBOARDING_TYPES = {
ONBOARDING_TYPES: dict[str, dict[str, Any]] = {
"rpg": {
"label": "Créer un jeu 2D ludique",
"tagline": "Ton monde, ton héros, ton scénario.",
@@ -57,7 +60,7 @@ ONBOARDING_TYPES = {
}
def get_onboarding_type_raw(slug):
def get_onboarding_type_raw(slug: str) -> str | None:
"""None si _meta['onboarding_type'] est absent — distingue un projet
JAMAIS passé par l'onboarding guidé (créé avant son existence, ou par
l'admin via "+ Nouveau jeu") d'un projet explicitement "custom"."""
@@ -67,11 +70,11 @@ def get_onboarding_type_raw(slug):
return row["value"] if row else None
def get_onboarding_type(slug):
def get_onboarding_type(slug: str) -> str:
return get_onboarding_type_raw(slug) or DEFAULT_ONBOARDING_TYPE
def set_onboarding_type(slug, onboarding_type):
def set_onboarding_type(slug: str, onboarding_type: str) -> None:
conn = db.connect(slug)
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('onboarding_type', ?)",
@@ -81,15 +84,14 @@ def set_onboarding_type(slug, onboarding_type):
conn.close()
def is_restricted(onboarding_type_or_slug):
def is_restricted(onboarding_type_or_slug: str) -> bool:
"""Accepte directement une clé de ONBOARDING_TYPES, ou un slug de
projet (résout alors son onboarding_type d'abord) — pratique aussi
bien pour core/auth_guard.py (a le slug) que pour un test unitaire (a
déjà la clé)."""
onboarding_type = (
onboarding_type_or_slug if onboarding_type_or_slug in ONBOARDING_TYPES
onboarding_type_or_slug
if onboarding_type_or_slug in ONBOARDING_TYPES
else get_onboarding_type(onboarding_type_or_slug)
)
return ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"]
return bool(ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"])
+1 -1
View File
@@ -3,7 +3,7 @@ from ..connection import connect
DEFAULT_GAME_TYPE = "jeu_2d"
def get_game_type(slug):
def get_game_type(slug: str) -> str:
"""Type de jeu choisi à la création (voir create_game.py) : "document"
(éditeur générique actuel — quiz/formulaires/contenus, écrans =
éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié —
+2 -2
View File
@@ -4,7 +4,7 @@ DEFAULT_SCORM_VERSION = "1.2"
VALID_SCORM_VERSIONS = ("1.2", "2004")
def get_scorm_version(slug):
def get_scorm_version(slug: str) -> str:
"""Version SCORM exportée pour ce jeu (voir publish/build_scorm_package.py,
publish/scorm_manifest.py) — '1.2' par défaut (compatibilité la plus
large) ou '2004' (3rd/4th edition, sépare completion_status/
@@ -16,4 +16,4 @@ def get_scorm_version(slug):
conn.close()
if row is None or row["value"] not in VALID_SCORM_VERSIONS:
return DEFAULT_SCORM_VERSION
return row["value"]
return str(row["value"])
+2 -4
View File
@@ -3,16 +3,14 @@ from ..connection import connect
DEFAULT_SUCCESS_THRESHOLD_PERCENT = 70
def get_success_threshold(slug):
def get_success_threshold(slug: str) -> int:
"""Seuil de réussite (% de bonnes réponses au quiz) de CE jeu — stocké
dans _meta (clé 'success_threshold_percent'), même convention que
get_xapi_settings.py. Détermine le statut SCORM/xAPI de fin de partie
(reussi/echoue, voir static/js/play/dialogue-box-controller.js::
forgeSyncAllQuestsCompletionToScorm) : 70% par défaut si jamais réglé."""
conn = connect(slug)
row = conn.execute(
"SELECT value FROM _meta WHERE key = 'success_threshold_percent'"
).fetchone()
row = conn.execute("SELECT value FROM _meta WHERE key = 'success_threshold_percent'").fetchone()
conn.close()
if row is None or row["value"] in (None, ""):
return DEFAULT_SUCCESS_THRESHOLD_PERCENT
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def get_xapi_settings(slug):
def get_xapi_settings(slug: str) -> dict[str, str]:
"""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)
+5 -4
View File
@@ -1,12 +1,13 @@
import os
from typing import Any
from ..constants import PROJECTS_DIR
from ..connection import connect
from ..constants import PROJECTS_DIR
from ..db_path import db_path
from .project_slug import build_slug
def list_games(owner_folder):
def list_games(owner_folder: str) -> list[dict[str, Any]]:
"""Scanne UNIQUEMENT projects/<owner_folder>/ (voir db/games/
project_slug.py — owner_folder est l'id du compte, voir
db/games/create_game.py) : jamais les autres comptes — voir
@@ -17,7 +18,7 @@ def list_games(owner_folder):
fonction ne lit plus jamais l'arborescence project/ en entier (avant
ce correctif, elle listait TOUS les comptes sans distinction — faille
corrigée, voir routes/games/index.py)."""
games = []
games: list[dict[str, Any]] = []
entry_path = os.path.join(PROJECTS_DIR, owner_folder)
if not os.path.isdir(entry_path):
return games
@@ -28,7 +29,7 @@ def list_games(owner_folder):
return games
def _append_game(games, slug):
def _append_game(games: list[dict[str, Any]], slug: str) -> None:
conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close()
+1 -1
View File
@@ -5,7 +5,7 @@ from ..game_dir import game_dir
from .project_slug import build_slug, split_slug
def move_game(old_slug, new_owner_folder):
def move_game(old_slug: str, new_owner_folder: str) -> str:
"""Renomme le dossier PROPRIÉTAIRE d'un compte — owner_folder est
l'id du compte (voir db/games/project_slug.py, routes/games/
games_new.py) : ne change donc plus jamais après coup en usage normal,
+2 -2
View File
@@ -17,11 +17,11 @@ move_game, list_games) savent que le slug encode ce chemin composé — si
la convention change un jour, ce fichier est le seul à modifier."""
def build_slug(owner_folder, project_part):
def build_slug(owner_folder: str, project_part: str) -> str:
return f"{owner_folder}_{project_part}"
def split_slug(slug):
def split_slug(slug: str) -> tuple[str, str | None]:
"""(owner_folder, project_part) si `slug` est bien composé, sinon
(slug, None) — un slug "plat" (créé avant cette convention, pas encore
migré par scripts/migrate_flat_project_slugs.py) reste lisible tel
+1 -1
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .get_scorm_version import VALID_SCORM_VERSIONS
def set_scorm_version(slug, version):
def set_scorm_version(slug: str, version: str) -> None:
"""Enregistre la version SCORM exportée — voir get_scorm_version.py."""
if version not in VALID_SCORM_VERSIONS:
raise ValueError("version SCORM invalide : {!r}".format(version))
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def set_success_threshold(slug, percent):
def set_success_threshold(slug: str, percent: int) -> int:
"""Enregistre le seuil de réussite (0-100) — voir get_success_threshold.py."""
percent = max(0, min(100, int(percent)))
conn = connect(slug)
+4 -10
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def set_xapi_settings(slug, endpoint, login, password=None):
def set_xapi_settings(slug: str, endpoint: str, login: str, password: str | None = None) -> 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à
@@ -10,15 +10,9 @@ def set_xapi_settings(slug, endpoint, login, password=None):
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 "",)
)
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.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,))
conn.commit()
conn.close()
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def update_game_name(slug, new_name):
def update_game_name(slug: str, new_name: str) -> None:
conn = connect(slug)
conn.execute("UPDATE _meta SET value = ? WHERE key = 'name'", (new_name,))
conn.commit()
+1 -1
View File
@@ -1,7 +1,7 @@
import json
def coerce_structured_value(var_type, value):
def coerce_structured_value(var_type: str, value: str) -> str:
"""Pour une variable "objet"/"tableau" (voir db/constants.py), la
valeur stockée doit rester du JSON analysable — sinon la prochaine
lecture (_resolve_variable_path, screens/rendering/
+6 -4
View File
@@ -1,9 +1,11 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
from .coerce_structured_value import coerce_structured_value
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
def create_global_variable(slug, name, var_type="texte", default_value="", per_player=True):
def create_global_variable(
slug: str, name: str, var_type: str = "texte", default_value: str = "", per_player: bool = True
) -> int | None:
"""Crée la ligne "modèle" d'une variable globale (toujours
player_id=PLAYER_SHARED, quel que soit per_player — voir
ensure_global_vars_schema.py) si son nom n'existe pas déjà ; si elle
@@ -28,12 +30,12 @@ def create_global_variable(slug, name, var_type="texte", default_value="", per_p
).fetchone()
if existing:
conn.close()
return existing["id"]
return int(existing["id"])
conn.execute(
"INSERT INTO _global_variables (name, type, value, player_id, per_player) VALUES (?, ?, ?, ?, ?)",
(name, var_type, default_value, PLAYER_SHARED, 1 if per_player else 0),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
new_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return new_id
+1 -1
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def delete_global_variable(slug, name):
def delete_global_variable(slug: str, name: str) -> None:
"""Supprime TOUTES les lignes de ce nom, pas seulement la ligne modèle
PLAYER_SHARED — une variable per_player peut avoir une ligne par joueur
ayant déjà écrit dessus (voir update_global_variable_value.py)."""
@@ -2,7 +2,7 @@ from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def delete_global_variable_by_id(slug, var_id):
def delete_global_variable_by_id(slug: str, var_id: int) -> None:
"""Version par id de delete_global_variable — utilisée par la page de
gestion des variables (game_variables.html), où chaque ligne connaît son
id technique mais pas nécessairement un nom encore garanti unique côté
+1 -1
View File
@@ -3,7 +3,7 @@ from ..connection import connect
PLAYER_SHARED = "__shared__"
def ensure_global_vars_schema(slug):
def ensure_global_vars_schema(slug: str) -> None:
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
crée _global_variables si absente. Depuis l'état par joueur (Phase 1),
une variable a une ligne "modèle" créée par le créateur du jeu
+4 -2
View File
@@ -1,9 +1,11 @@
from typing import Any
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
from .resolve_player_key import resolve_player_key
def get_global_variable(slug, name, player_id=PLAYER_SHARED):
def get_global_variable(slug: str, name: str, player_id: str = PLAYER_SHARED) -> dict[str, Any] | None:
"""Par NOM (pas par id — c'est comme ça qu'une variable est référencée
partout ailleurs, condition de visibilité, action "Modifier une
variable") et par joueur (voir resolve_player_key.py) : une variable
+4 -2
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
def list_global_variables(slug):
def list_global_variables(slug: str) -> list[dict[str, Any]]:
"""Toujours les lignes MODÈLES (PLAYER_SHARED) — c'est la liste des
variables telle que le créateur les a définies dans le tableau de
bord, jamais influencée par le nombre de joueurs ayant déjà leur
@@ -1,9 +1,11 @@
from typing import Any
from .ensure_global_vars_schema import PLAYER_SHARED
from .list_global_variables import list_global_variables
from .get_global_variable import get_global_variable
from .list_global_variables import list_global_variables
def list_global_variables_for_player(slug, player_id=PLAYER_SHARED):
def list_global_variables_for_player(slug: str, player_id: str = PLAYER_SHARED) -> list[dict[str, Any]]:
"""Valeur EFFECTIVE de chaque variable pour ce joueur (voir
get_global_variable.py : la ligne propre à ce joueur si elle existe
déjà, sinon la valeur par défaut de la ligne modèle) — à la différence
@@ -11,7 +13,7 @@ def list_global_variables_for_player(slug, player_id=PLAYER_SHARED):
tableau de bord), c'est cette version qu'il faut exposer au runtime
jouable (full_game_payload.py), pour qu'une condition évaluée côté
client reflète bien l'état RÉEL de CE joueur."""
result = []
result: list[dict[str, Any]] = []
for template in list_global_variables(slug):
value = get_global_variable(slug, template["name"], player_id)
result.append(value if value else template)
+3 -1
View File
@@ -1,7 +1,9 @@
import sqlite3
from .ensure_global_vars_schema import PLAYER_SHARED
def resolve_player_key(conn, name, player_id):
def resolve_player_key(conn: sqlite3.Connection, name: str, player_id: str) -> str:
"""Détermine le player_id à utiliser RÉELLEMENT pour lire/écrire cette
variable : celui du joueur (variable per_player=1, le cas par défaut),
ou toujours PLAYER_SHARED (variable explicitement partagée entre tous
+4 -2
View File
@@ -1,9 +1,11 @@
from typing import Any
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
from .coerce_structured_value import coerce_structured_value
from .ensure_global_vars_schema import ensure_global_vars_schema
def update_global_variable(slug, var_id, var_type, value):
def update_global_variable(slug: str, var_id: int, var_type: str, value: Any) -> None:
"""Modifie le type et la valeur d'une variable existante depuis le
tableau de bord (onglet Variables, game_dashboard.html) — le NOM reste
volontairement immuable après création : c'est par ce nom qu'une
@@ -1,9 +1,11 @@
from typing import Any
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
from .resolve_player_key import resolve_player_key
def update_global_variable_value(slug, name, new_value, player_id=PLAYER_SHARED):
def update_global_variable_value(slug: str, name: str, new_value: Any, player_id: str = PLAYER_SHARED) -> bool:
"""Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant) sur la
variable — utilisé aussi bien par l'action "Modifier une variable" (voir
screens/data_actions/apply_variable_action.py) que par une éventuelle
@@ -17,9 +19,7 @@ def update_global_variable_value(slug, name, new_value, player_id=PLAYER_SHARED)
ensure_global_vars_schema(slug)
conn = connect(slug)
key = resolve_player_key(conn, name, player_id)
existing = conn.execute(
"SELECT id FROM _global_variables WHERE name = ? AND player_id = ?", (name, key)
).fetchone()
existing = conn.execute("SELECT id FROM _global_variables WHERE name = ? AND player_id = ?", (name, key)).fetchone()
if existing:
conn.execute("UPDATE _global_variables SET value = ? WHERE id = ?", (str(new_value), existing["id"]))
else:
+15
View File
@@ -0,0 +1,15 @@
import json
from typing import Any
def json_for_script(data: Any) -> str:
"""`json.dumps(data)` n'échappe jamais "<"/">"/"&" — une valeur
contenant le texte "</script>" (nom d'écran/objet/variable, texte de
dialogue... n'importe quel champ libre du créateur) refermerait
prématurément la balise <script> qui embarque ce JSON dans un template
(voir templates/play.html, templates/scene_edit.html), rendant tout ce
qui suit du HTML/JS brut et non échappé sur la page. `.replace("</",
"<\\/")` neutralise ce cas sans changer la valeur JSON une fois
parsée côté navigateur (une barre oblique inversée devant "/" est un
échappement JSON valide, ignoré par JSON.parse())."""
return json.dumps(data).replace("</", "<\\/")
+1 -1
View File
@@ -1,4 +1,4 @@
def quote_ident(name):
def quote_ident(name: str) -> str:
"""Encadre un identifiant SQL (nom de colonne) de guillemets doubles —
forme standard SQL, supportée par SQLite, pour pouvoir utiliser un nom
de colonne qui serait sinon un mot réservé (ex. un champ appelé
+8 -3
View File
@@ -1,12 +1,17 @@
from typing import Any
from ..connection import connect
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
def delete_row(slug, definition, row_id, player_id=PLAYER_SHARED):
def delete_row(slug: str, definition: dict[str, Any], row_id: int, player_id: str = PLAYER_SHARED) -> None:
ensure_player_id_column(slug, definition["table_name"])
key = resolve_row_player_key(definition, player_id)
conn = connect(slug)
conn.execute(f"DELETE FROM {definition['table_name']} WHERE id = ? AND player_id = ?", (row_id, key))
conn.execute(
f"DELETE FROM {definition['table_name']} WHERE id = ? AND player_id = ?", # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_, jamais d'une entree brute ; valeurs parametrees (?)
(row_id, key),
)
conn.commit()
conn.close()
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def ensure_player_id_column(slug, table_name):
def ensure_player_id_column(slug: str, table_name: str) -> None:
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
ajoute player_id à la table d'un objet créé avant l'état par joueur
(Phase 1) — ses lignes existantes deviennent implicitement
+8 -3
View File
@@ -1,9 +1,13 @@
from typing import Any
from ..connection import connect
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
def get_row(slug, definition, row_id, player_id=PLAYER_SHARED):
def get_row(
slug: str, definition: dict[str, Any], row_id: int, player_id: str = PLAYER_SHARED
) -> dict[str, Any] | None:
"""Filtre aussi par player_id (pas seulement id) : même si `row_id` est
déjà un identifiant unique dans toute la table, ce garde-fou empêche un
joueur de lire la ligne d'un AUTRE joueur en devinant/altérant un
@@ -13,7 +17,8 @@ def get_row(slug, definition, row_id, player_id=PLAYER_SHARED):
key = resolve_row_player_key(definition, player_id)
conn = connect(slug)
row = conn.execute(
f"SELECT * FROM {definition['table_name']} WHERE id = ? AND player_id = ?", (row_id, key)
f"SELECT * FROM {definition['table_name']} WHERE id = ? AND player_id = ?", # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_, jamais d'une entree brute ; valeurs parametrees (?)
(row_id, key),
).fetchone()
conn.close()
return dict(row) if row else None
+9 -5
View File
@@ -1,11 +1,15 @@
from typing import Any, Mapping
from ..connection import connect
from ..quote_ident import quote_ident
from .row_columns_and_values import row_columns_and_values
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
from .row_columns_and_values import row_columns_and_values
def insert_row(slug, definition, form_data, player_id=PLAYER_SHARED):
def insert_row(
slug: str, definition: dict[str, Any], form_data: Mapping[str, str], player_id: str = PLAYER_SHARED
) -> int:
"""Feature 3 : enregistre en base les données saisies dans le formulaire
généré à partir de la définition d'objet."""
ensure_player_id_column(slug, definition["table_name"])
@@ -16,9 +20,9 @@ def insert_row(slug, definition, form_data, player_id=PLAYER_SHARED):
conn = connect(slug)
placeholders = ["?"] * len(cols)
quoted_cols = ", ".join(quote_ident(c) for c in cols)
sql = f"INSERT INTO {definition['table_name']} ({quoted_cols}) VALUES ({', '.join(placeholders)})"
sql = f"INSERT INTO {definition['table_name']} ({quoted_cols}) VALUES ({', '.join(placeholders)})" # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_ (db/table_name_for.py) ; colonnes deja echappees via quote_ident() ; valeurs parametrees (?)
conn.execute(sql, values)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
new_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return new_id
+6 -3
View File
@@ -1,14 +1,17 @@
from typing import Any
from ..connection import connect
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
def list_rows(slug, definition, player_id=PLAYER_SHARED):
def list_rows(slug: str, definition: dict[str, Any], player_id: str = PLAYER_SHARED) -> list[dict[str, Any]]:
ensure_player_id_column(slug, definition["table_name"])
key = resolve_row_player_key(definition, player_id)
conn = connect(slug)
rows = conn.execute(
f"SELECT * FROM {definition['table_name']} WHERE player_id = ? ORDER BY id DESC", (key,)
f"SELECT * FROM {definition['table_name']} WHERE player_id = ? ORDER BY id DESC", # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_, jamais d'une entree brute ; valeurs parametrees (?)
(key,),
).fetchall()
conn.close()
return [dict(r) for r in rows]
-27
View File
@@ -1,27 +0,0 @@
from ..connection import connect
from ..slugify import slugify
from ..definitions.get_definition import get_definition
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import PLAYER_SHARED
def relation_options(slug, related_definition_id):
"""Lignes existantes d'un objet lié, pour peupler un <select> de
relation — toujours la vue "modèle"/créateur (PLAYER_SHARED), cette
fonction n'est utilisée que côté tableau de bord/éditeur, jamais
depuis la route publique /jouer/<slug>."""
related = get_definition(slug, related_definition_id)
ensure_player_id_column(slug, related["table_name"])
conn = connect(slug)
rows = conn.execute(
f"SELECT * FROM {related['table_name']} WHERE player_id = ? ORDER BY id", (PLAYER_SHARED,)
).fetchall()
conn.close()
display_field = related["fields"][0]["name"] if related["fields"] else "id"
display_col = slugify(display_field).replace("-", "_")
options = []
for r in rows:
r = dict(r)
label = r.get(display_col, f"#{r['id']}")
options.append({"id": r["id"], "label": f"{label} (#{r['id']})"})
return options
+3 -1
View File
@@ -1,7 +1,9 @@
from typing import Any
PLAYER_SHARED = "__shared__"
def resolve_row_player_key(definition, player_id):
def resolve_row_player_key(definition: dict[str, Any], player_id: str) -> str:
"""Détermine le player_id à utiliser réellement pour lire/écrire les
lignes de cet objet : celui du joueur (objet per_player=1, le cas par
défaut — voir create_definition.py), ou toujours PLAYER_SHARED (objet
+7 -2
View File
@@ -1,11 +1,16 @@
from typing import Any, Mapping
from ..slugify import slugify
def row_columns_and_values(definition, form_data):
def row_columns_and_values(
definition: dict[str, Any], form_data: Mapping[str, str]
) -> tuple[list[str], list[int | float | str | None]]:
"""Traduit les données d'un formulaire généré en (colonnes, valeurs)
prêtes pour un INSERT ou un UPDATE — factorisé pour que les deux
partagent exactement la même logique de conversion de type."""
cols, values = [], []
cols: list[str] = []
values: list[int | float | str | None] = []
for f in definition["fields"]:
fname = slugify(f["name"]).replace("-", "_")
if f["type"] == "relation":
-32
View File
@@ -1,32 +0,0 @@
from ..connection import connect
from ..quote_ident import quote_ident
from ..slugify import slugify
from ..definitions.definitions_referencing import definitions_referencing
from ..definitions.get_definition import get_definition
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import PLAYER_SHARED
def rows_referencing(slug, definition_id, row_id):
"""Pour l'écran de suppression d'une ligne : liste, pour chaque objet
qui a une relation vers l'objet de cette ligne, combien de ses lignes
pointent vers CETTE ligne précise — pour avertir avant une suppression
qui laisserait des relations orphelines (aucune suppression en cascade
n'est faite automatiquement, par sécurité). Toujours la vue "modèle"/
créateur (PLAYER_SHARED), utilisée uniquement côté tableau de bord."""
refs = []
for d in definitions_referencing(slug, definition_id):
full = get_definition(slug, d["id"])
for f in full["fields"]:
if f["type"] == "relation" and f["relation_definition_id"] == definition_id:
col = slugify(f["name"]).replace("-", "_") + "_id"
ensure_player_id_column(slug, full["table_name"])
conn = connect(slug)
count = conn.execute(
f"SELECT COUNT(*) AS c FROM {full['table_name']} WHERE {quote_ident(col)} = ? AND player_id = ?",
(row_id, PLAYER_SHARED),
).fetchone()["c"]
conn.close()
if count:
refs.append({"definition_name": full["name"], "field_name": f["name"], "count": count})
return refs
+8 -4
View File
@@ -1,11 +1,15 @@
from typing import Any, Mapping
from ..connection import connect
from ..quote_ident import quote_ident
from .row_columns_and_values import row_columns_and_values
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
from .row_columns_and_values import row_columns_and_values
def update_row(slug, definition, row_id, form_data, player_id=PLAYER_SHARED):
def update_row(
slug: str, definition: dict[str, Any], row_id: int, form_data: Mapping[str, str], player_id: str = PLAYER_SHARED
) -> None:
"""CRUD — Update : ré-enregistre une ligne existante avec les nouvelles
valeurs saisies dans le même formulaire généré que pour la création.
Filtre aussi par player_id (voir get_row.py — même garde-fou contre un
@@ -16,7 +20,7 @@ def update_row(slug, definition, row_id, form_data, player_id=PLAYER_SHARED):
conn = connect(slug)
set_clause = ", ".join(f"{quote_ident(c)} = ?" for c in cols)
conn.execute(
f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ? AND player_id = ?",
f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ? AND player_id = ?", # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_ (db/table_name_for.py) ; set_clause deja echappe via quote_ident() ; valeurs parametrees (?)
values + [row_id, key],
)
conn.commit()
+12 -3
View File
@@ -1,11 +1,20 @@
from typing import Any
from ..connection import connect
from ..quote_ident import quote_ident
from ..slugify import slugify
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
def update_row_field(slug, definition, row_id, field_def, new_value, player_id=PLAYER_SHARED):
def update_row_field(
slug: str,
definition: dict[str, Any],
row_id: int,
field_def: dict[str, Any],
new_value: Any,
player_id: str = PLAYER_SHARED,
) -> None:
"""Met à jour UNE SEULE colonne d'une ligne existante — utilisé par les
actions "Modifier une donnée" du mode jouable, qui ne doivent jamais
écraser les autres champs de la ligne (contrairement à update_row, qui
@@ -17,7 +26,7 @@ def update_row_field(slug, definition, row_id, field_def, new_value, player_id=P
col = f"{fname}_id" if field_def["type"] == "relation" else fname
conn = connect(slug)
conn.execute(
f"UPDATE {definition['table_name']} SET {quote_ident(col)} = ? WHERE id = ? AND player_id = ?",
f"UPDATE {definition['table_name']} SET {quote_ident(col)} = ? WHERE id = ? AND player_id = ?", # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_ (db/table_name_for.py) ; col deja echappee via quote_ident() ; valeurs parametrees (?)
(new_value, row_id, key),
)
conn.commit()
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def ensure_scoring_schema(slug):
def ensure_scoring_schema(slug: str) -> None:
"""Migration légère (même principe que ensure_global_vars_schema.py) :
crée _scoring si absente. Une seule ligne par joueur (player_id, voir
db.PLAYER_SHARED pour l'aperçu créateur — même modèle que
+3 -1
View File
@@ -1,9 +1,11 @@
from typing import Any
from ..connection import connect
from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED
from .ensure_scoring_schema import ensure_scoring_schema
def get_score(slug, player_id=PLAYER_SHARED):
def get_score(slug: str, player_id: str = PLAYER_SHARED) -> dict[str, Any]:
"""Score/statut de CE joueur — (0, "non_commence") tant qu'il n'a
encore rien écrit (voir set_score_value.py/set_status.py, qui créent
la ligne à la première écriture, même patron que
+1 -1
View File
@@ -3,7 +3,7 @@ from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED
from .ensure_scoring_schema import ensure_scoring_schema
def set_score_value(slug, new_value, player_id=PLAYER_SHARED):
def set_score_value(slug: str, new_value: int | float, player_id: str = PLAYER_SHARED) -> bool:
"""Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant, voir
screens/data_actions/apply_score_action.py) — même patron que
update_global_variable_value.py : la ligne de ce joueur est créée à sa
+1 -1
View File
@@ -4,7 +4,7 @@ from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED
from .ensure_scoring_schema import ensure_scoring_schema
def set_status(slug, status, player_id=PLAYER_SHARED):
def set_status(slug: str, status: str, player_id: str = PLAYER_SHARED) -> bool:
"""Voir set_score_value.py pour le patron (upsert, ligne créée à la
première écriture). `status` doit être une clé de
db.SCORE_STATUS_LABELS — un statut inconnu (ex. faute de frappe dans
+1 -1
View File
@@ -1,7 +1,7 @@
import re
def slugify(name):
def slugify(name: str) -> str:
s = name.strip().lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
s = re.sub(r"-+", "-", s).strip("-")
+1 -1
View File
@@ -1,6 +1,6 @@
from .slugify import slugify
def table_name_for(object_name):
def table_name_for(object_name: str) -> str:
"""Nom de la vraie table SQL pour un objet défini par l'utilisateur."""
return "obj_" + slugify(object_name).replace("-", "_")