diff --git a/.env.example b/.env.example index 6579f415..17e694fb 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,9 @@ ANTHROPIC_API_KEY= SCENARIO_API_KEY= SCENARIO_API_SECRET= SCENARIO_MODEL_ID= + +# Debogueur Werkzeug (traceback interactif + auto-reload) pour le +# developpement local uniquement — ne jamais activer ailleurs qu'en local +# (app.py, python app.py direct ; sans effet en production, qui utilise +# gunicorn). Laisser vide/0 = desactive par defaut. +FORGE_DEBUG=0 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..d6dba23c --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,191 @@ +{ + "root": true, + "env": { + "browser": true, + "es2021": true + }, + "extends": ["airbnb-base"], + "plugins": ["unused-imports"], + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "script" + }, + "rules": { + "max-len": ["error", 120, 2, { + "ignoreUrls": true, + "ignoreComments": false, + "ignoreRegExpLiterals": true, + "ignoreStrings": true, + "ignoreTemplateLiterals": true + }], + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "error", + { + "vars": "all", + "args": "after-used", + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^(_|addClauseRow|addPersonnageCommandExtraRow|applyCameraSize|bindClicks|bindHeldKeyTriggers|bindHoverTexts|bindHoverTriggers|bindKeyboardTriggers|bindPropsAutosave|closeLeftPanel|closePropsPanel|collisionWizardAssetNext|collisionWizardAttendreNext|collisionWizardChainDone|collisionWizardChooseAction|collisionWizardChooseBranch|collisionWizardChooseTrigger|collisionWizardConditionNext|collisionWizardIndicationNext|collisionWizardObjectTargetChosen|collisionWizardValeurChosen|collisionWizardVariableNext|collisionWizardVideoModeChosen|confirmDeleteElement|createGlobalVariable|deleteGlobalVariable|deleteSceneObject|deleteUserAsset|forgeShowTab|iaChatDeleteConversation|iaChatInputKeydown|iaChatSelectConversation|initSceneBuilderPanel|injectCustomKeyframes|onCanvasDragOver|onCanvasDrop|onCollisionBoxMouseDown|onCollisionBoxResizeMouseDown|onElementMouseDown|onFilterValueFieldChange|onFilterValueModeChange|onFilterValueObjChange|onFilterValueVarChange|onGalleryTileDragStart|onResizeMouseDown|onSceneObjectMouseDown|onSceneObjectResizeMouseDown|openAddTriggerModal|openLeftPanel|openPropsPanel|openScreenAddTriggerModal|openScreenTriggerWizard|openTriggerWizard|reclassifyUserAsset|refreshRuntimeData|restartPersonnageIdlePreview|restoreFloatPanelState|runScreenHeldKeyTriggers|runScreenShowTriggers|runScreenTimerTriggers|saveDialogueBoxStyle|saveGlobalVariable|saveSceneObjectName|saveSceneObjectRole|screenIndexById|screenTriggerAppendLeaf|screenTriggerDeleteAt|screenTriggerOpenAppendActionModal|selectQuizBoxDialogTemplate|selectQuizBoxPageTemplate|showScreen|swapPersonnageCharacter|swapSceneObjectCharacter|switchBuilderTab|syncAlign|toggleDashCreate|triggerAppendLeaf|triggerClickObjectCard|triggerDeleteTrigger|triggerInlineAddBubble|triggerInlineAddQuestion|triggerInlineDeleteBubble|triggerInlineSetBubbleAudio|triggerInlineSetBubbleSpeaker|triggerInlineSetQuestionChoiceCount|triggerInlineSetQuestionChoiceText|triggerInlineSetQuestionCorrectIndex|triggerInlineSetQuestionRewardAmount|triggerInlineUpdateBubbleText|triggerInlineUpdateQuestionText|triggerMoveChainAction|triggerOpenAppendActionModal|triggerRemoveChainAction|triggerSelectObject|uploadUserAsset)$" + } + ], + "no-unused-vars": "off", + "no-underscore-dangle": "off", + "func-names": "off", + "no-param-reassign": "off", + "no-cond-assign": ["error", "except-parens"], + "no-empty": ["error", { "allowEmptyCatch": true }], + "guard-for-in": "off", + "no-unused-expressions": "off", + "no-return-assign": "off", + "consistent-return": "off", + "no-bitwise": "off", + "no-plusplus": "off", + "no-restricted-syntax": "off", + "prefer-destructuring": "off", + "no-useless-concat": "off", + "no-console": "off", + "no-nested-ternary": "off", + "no-void": "off", + "no-continue": "off", + "implicit-arrow-linebreak": "off", + "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }] + }, + "overrides": [ + { + "files": ["static/js/**/__tests__/**/*.test.js"], + "env": { "node": true, "browser": true }, + "parserOptions": { "sourceType": "script" } + }, + { + "files": ["static/js/play/**/*.js"], + "rules": { + "import/extensions": "off", + "global-require": "off" + } + } + ], + "globals": { + "COLLISION_ACTION_LABELS": "readonly", + "COLLISION_CHAIN_CHOICES": "readonly", + "COLLISION_TRIGGER_LABELS": "readonly", + "CONDITION_OPERATOR_LABELS_MAP": "readonly", + "CURRENT_SCREEN_ID": "readonly", + "CURRENT_SELECTED_ID": "writable", + "DATA_OPERATION_LABELS_MAP": "readonly", + "DEFINITIONS_DATA": "readonly", + "ELEMENT_ADD_URL": "readonly", + "ELEMENT_ANIMATIONS_MAP": "readonly", + "ELEMENT_VISIBILITY_LABELS_MAP": "readonly", + "FORGE_PLAY_URLS": "readonly", + "GAME_SLUG": "readonly", + "GLOBAL_VARIABLE_NAMES": "readonly", + "SCENE_HEIGHT": "readonly", + "SCENE_OBJECT_NAMES": "writable", + "SCENE_OBJECT_NAMES_JSON": "readonly", + "SCENE_WIDTH": "readonly", + "SCREEN_EDIT_URL": "readonly", + "SCREEN_ID": "readonly", + "SURBRILLANCE_LABELS_MAP": "readonly", + "USER_ASSETS_OPTIONS": "readonly", + "VIDEO_MODE_LABELS_MAP": "readonly", + "_collisionWizard": "writable", + "_stopPersonnageIdlePreview": "readonly", + "applyObjectProperty": "readonly", + "applyOpenRowBindings": "readonly", + "applySelectionHighlight": "readonly", + "bindClicks": "readonly", + "bindHoverTexts": "readonly", + "bindHoverTriggers": "readonly", + "bindPropsAutosave": "readonly", + "clampSceneObjectPosition": "readonly", + "closeTriggerModal": "readonly", + "collisionRuleThumbHtml": "readonly", + "collisionWizardBuildLeafAction": "readonly", + "compareValues": "readonly", + "debouncedSubmitPropsForm": "readonly", + "evaluateConditionClause": "readonly", + "evaluateConditionNode": "readonly", + "forgeApplyAddRowActionOffline": "readonly", + "forgeApplyCtx": "readonly", + "forgeApplyDataActionOffline": "readonly", + "forgeApplyScoreActionOffline": "readonly", + "forgeApplyStatusActionOffline": "readonly", + "forgeApplyVariableActionOffline": "readonly", + "forgeAttrString": "readonly", + "forgeAutoId": "readonly", + "forgeCollisionRectFromBox": "readonly", + "forgeComputeNewValue": "readonly", + "forgeDecodeClauses": "readonly", + "forgeDialogueBoxState": "readonly", + "forgeEscapeHtml": "readonly", + "forgeFilterRepeaterRows": "readonly", + "forgeFilterRowsByClauses": "readonly", + "forgeHtmlEscape": "readonly", + "forgeIsElementVisibleOffline": "readonly", + "forgeParentFlexDirection": "readonly", + "forgeQuizBoxState": "readonly", + "forgeQuizTemplateEffects": "readonly", + "forgeRecomputeFullPayloadOffline": "readonly", + "forgeRenderCheckboxOrRadio": "readonly", + "forgeRenderChildren": "readonly", + "forgeRenderElementHtml": "readonly", + "forgeRenderFieldset": "readonly", + "forgeRenderIcone": "readonly", + "forgeRenderJauge": "readonly", + "forgeRenderOnglets": "readonly", + "forgeRenderOverlay": "readonly", + "forgeRenderPersonnage": "readonly", + "forgeRenderRepeater": "readonly", + "forgeRenderSelect": "readonly", + "forgeRenderTable": "readonly", + "forgeResolveFilterValue": "readonly", + "forgeResolveVariablePath": "readonly", + "forgeRunCollisionRuleAction": "readonly", + "forgeRunScreenTriggers": "readonly", + "forgeScorm2004NotifyQuestionAnswered": "readonly", + "forgeScormApi": "readonly", + "forgeShapesOverlap": "readonly", + "forgeShowDialogueBox": "readonly", + "forgeShowVideoOverlay": "readonly", + "forgeStartCollisionRuleControllers": "readonly", + "forgeStartPersonnageControllers": "readonly", + "forgeStyleString": "readonly", + "forgeUpdateAllScoreWidgets": "readonly", + "forgeVisibleAttrs": "readonly", + "forgeXapiNotifyDialogueCompleted": "readonly", + "forgeXapiNotifyQuestionAnswered": "readonly", + "forgeXapiNotifyScoreChanged": "readonly", + "forgeXapiNotifyStatusChanged": "readonly", + "gameData": "writable", + "goToSelected": "readonly", + "heldKeys": "readonly", + "initBuilderPanel": "readonly", + "initIaTab": "readonly", + "initSceneBuilderPanel": "readonly", + "initTriggersTab": "readonly", + "openPropsPanel": "readonly", + "openScreenTriggerWizard": "readonly", + "openTriggerWizard": "readonly", + "refreshRuntimeData": "readonly", + "renderCollisionWizardChainStep": "readonly", + "resolveSpriteFrames": "readonly", + "restartPersonnageIdlePreview": "readonly", + "restoreFloatPanelState": "readonly", + "restoreTreeCollapsedState": "readonly", + "runActionNode": "readonly", + "runFlowFrom": "readonly", + "runScreenHeldKeyTriggers": "readonly", + "runScreenShowTriggers": "readonly", + "runScreenTimerTriggers": "readonly", + "runSpriteAnimation": "readonly", + "saveGeometry": "readonly", + "screenIndexById": "readonly", + "screenTriggerLoadAll": "readonly", + "screensData": "writable", + "showScreen": "readonly", + "startAllPersonnagePreviews": "readonly", + "stopAllSpriteAnimations": "readonly", + "submitPropsForm": "readonly", + "triggerLoadAll": "readonly" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ac1a3e91 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Force LF partout, quel que soit le core.autocrlf de la machine locale +# (Windows le met souvent a true par defaut) — sans ca, un `git stash`/ +# checkout (declenche par exemple par les hooks pre-commit avant de tester +# le diff stage) reconvertit les fichiers en CRLF, ce qu'ESLint refuse +# ensuite (regle linebreak-style: LF, voir .eslintrc.json) alors que le +# fichier sur disque, lui, est deja en LF — decouvert en committant la +# Phase 3 du plan qualite. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore index abbafa1f..34dfec08 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,16 @@ __pycache__/ *.egg-info/ .pytest_cache/ +# JS (voir package.json) — installe via `npm install`, jamais committé. +node_modules/ + +# Rapports d'outils qualité (ruff/bandit/eslint/stylelint/mypy/vulture/ +# import-linter/djlint — voir sonar-project.properties, CODE_QUALITY.md) : +# sortie regenerée à la demande avant chaque scan Sonar, jamais figée dans +# l'historique (un rapport committé devient vite mensonger — vécu : ceux du +# 14 sept re-signalaient des centaines de problèmes déjà corrigés). +reports/ + # Environnements virtuels .venv/ venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..07cf6fcc --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,80 @@ +# Hooks locaux (language: system) plutot que les miroirs pre-commit +# habituels (astral-sh/ruff-pre-commit, pre-commit/mirrors-mypy, +# pre-commit/mirrors-eslint...) : ce projet a deja un environnement +# Python (requirements-dev.txt) et un node_modules/ (package.json) bien +# a lui — repartir sur un environnement ISOLE et RE-TELECHARGE par +# pre-commit dupliquerait exactement les memes outils/versions pour rien +# (et serait plus lent, plus fragile hors ligne). Voir CODE_QUALITY.md +# pour l'installation prealable (`pip install -r requirements-dev.txt` +# + `npm install`), necessaire pour que ces hooks trouvent les +# executables. +# +# Chaque hook est bloquant (aucune regle desactivee "pour ne pas casser +# le build", demande explicite) — un commit est refuse si un outil +# trouve un probleme, jusqu'a ce que la Phase 3 du plan qualite ait +# nettoye l'existant. +# +# eslint/stylelint : entry appelle `node ` directement plutot +# que `npx eslint` ou le shim node_modules/.bin/eslint(.cmd) — ces deux +# dernieres formes font passer pre-commit par cmd.exe pour lancer un +# executable Windows (.cmd) ou un script a shebang POSIX (#!/bin/sh), +# et pre-commit resout mal cmd.exe/sh depuis ce contexte (bug constate +# sous Windows). Appeler `node` directement sur le fichier JS du package +# evite tout intermediaire shell. +repos: + - repo: local + hooks: + - id: ruff-check + name: Ruff (lint Python) + entry: ruff check + language: system + types: [python] + + - id: ruff-format + name: Ruff (formatage Python) + entry: ruff format --check + language: system + types: [python] + + - id: mypy + name: Mypy (typage strict Python) + entry: mypy . + language: system + types: [python] + pass_filenames: false + + - id: vulture + name: Vulture (code mort Python) + entry: vulture + language: system + pass_filenames: false + + - id: bandit + name: Bandit (securite Python) + entry: bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py + language: system + pass_filenames: false + + - id: import-linter + name: import-linter (contrats d'architecture Python) + entry: lint-imports + language: system + pass_filenames: false + + - id: djlint + name: djLint (templates Jinja) + entry: djlint templates + language: system + pass_filenames: false + + - id: eslint + name: ESLint (JS) + entry: node node_modules/eslint/bin/eslint.js + language: system + files: ^static/js/.*\.js$ + + - id: stylelint + name: Stylelint (CSS) + entry: node node_modules/stylelint/bin/stylelint.mjs + language: system + files: \.css$ diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 00000000..f1f8efad --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,12 @@ +{ + "extends": ["stylelint-config-standard"], + "ignoreFiles": [ + "static/vendor/**/*.css", + "static/style.css" + ], + "rules": { + "selector-class-pattern": null, + "selector-id-pattern": null, + "no-descending-specificity": null + } +} diff --git a/ai/__init__.py b/ai/__init__.py index d6541c65..43d54c1e 100644 --- a/ai/__init__.py +++ b/ai/__init__.py @@ -3,13 +3,19 @@ screens/ (rendu/données de jeu) : ai.tools définit les outils exposés à l'agent (Phase 2) sans dépendre du SDK anthropic lui-même, pour rester testable sans clé API.""" -from .tools import TOOLS, dispatch_tool -from .client import get_client, AnthropicNotConfiguredError, MODEL -from .scenario_client import generate_image_url, ScenarioNotConfiguredError, ScenarioGenerationError from .chat import run_chat_turn +from .client import MODEL, AnthropicNotConfiguredError, get_client +from .scenario_client import ScenarioGenerationError, ScenarioNotConfiguredError, generate_image_url +from .tools import TOOLS, dispatch_tool __all__ = [ - "TOOLS", "dispatch_tool", "get_client", "AnthropicNotConfiguredError", "MODEL", - "generate_image_url", "ScenarioNotConfiguredError", "ScenarioGenerationError", + "TOOLS", + "dispatch_tool", + "get_client", + "AnthropicNotConfiguredError", + "MODEL", + "generate_image_url", + "ScenarioNotConfiguredError", + "ScenarioGenerationError", "run_chat_turn", ] diff --git a/ai/chat.py b/ai/chat.py index f72d486e..f930ee85 100644 --- a/ai/chat.py +++ b/ai/chat.py @@ -2,12 +2,14 @@ UNIQUEMENT les tools de ai/tools.py, jamais d'écriture directe en base : tout ce qu'un tour de chat produit est donc TOUJOURS relisable/ modifiable dans l'éditeur normal (même garantie que la Phase 1).""" + import json +from typing import Any import db import screens -from .client import get_client, MODEL +from .client import MODEL, get_client from .tools import TOOLS, dispatch_tool # Borne dure : jamais une boucle sans fin qui dépenserait sans fin si @@ -25,10 +27,10 @@ _SYSTEM_PROMPT = ( "Réutilise une variable globale déjà existante UNIQUEMENT si elle " "représente EXACTEMENT la même information (ex. un score total " "partagé par tout le jeu) — les variables sont globales à TOUT le " - "jeu, visibles depuis n'importe quel écran, donc un drapeau \"terminé\" " + 'jeu, visibles depuis n\'importe quel écran, donc un drapeau "terminé" ' "propre à UN dialogue/quiz précis ne doit JAMAIS être partagé avec un " "autre dialogue/quiz, même similaire, même sur un autre écran (bug " - "corrigé : un quiz réutilisait par erreur le drapeau \"terminé\" d'un " + 'corrigé : un quiz réutilisait par erreur le drapeau "terminé" d\'un ' "AUTRE quiz sans lien, les rendant mutuellement incohérents). Donne à " "chaque nouveau drapeau un nom qui identifie clairement CE qu'il " "suit (ex. préfixé par le nom du personnage/objet concerné). " @@ -40,10 +42,10 @@ _SYSTEM_PROMPT = ( "CE QUE TU NE PEUX PAS FAIRE : tes outils pilotent la logique " "\"déclencheur -> action\" d'un objet de scène OU de l'écran entier " "(voir DÉCLENCHEURS/ACTIONS ci-dessous). Forge Engine a un AUTRE " - "système, séparé, de \"flow\" (nœuds/liaisons) pour changer d'écran, " + 'système, séparé, de "flow" (nœuds/liaisons) pour changer d\'écran, ' "les minuteurs récurrents, une animation de sprite — tu n'as AUCUN " - "outil pour ça. Si une demande a besoin de cette partie-là (\"passer " - "à l'écran suivant\", \"après 5 secondes\"...), fais quand même tout " + 'outil pour ça. Si une demande a besoin de cette partie-là ("passer ' + 'à l\'écran suivant", "après 5 secondes"...), fais quand même tout ' "ce que tes outils permettent, puis DIS CLAIREMENT dans ta réponse " "texte ce que tu n'as pas pu faire et pourquoi (cette partie doit " "être ajoutée à la main dans l'onglet \"Flow\") — ne t'acharne JAMAIS " @@ -51,29 +53,29 @@ _SYSTEM_PROMPT = ( "permet, ça n'aboutira jamais." "\n\n" "DÉCLENCHEURS DISPONIBLES (set_collision_rules, sur un objet) : " - "\"collision\" (contact avec le personnage \"joueur\" — a besoin d'un " - "joueur, voir JOUEUR ET PNJ), \"clic\" (l'objet est cliqué/touché, " + '"collision" (contact avec le personnage "joueur" — a besoin d\'un ' + 'joueur, voir JOUEUR ET PNJ), "clic" (l\'objet est cliqué/touché, ' "aucun joueur requis — typique d'un panneau, un bouton, un objet " "d'interface statique), \"survol\" (le pointeur survole l'objet, " "aucun joueur requis — typique d'une info contextuelle affichée sans " "action du joueur). DÉCLENCHEUR D'ÉCRAN (set_screen_triggers, SANS " - "objet requis) : \"affichage\" — se déclenche dès que l'écran " + 'objet requis) : "affichage" — se déclenche dès que l\'écran ' "apparaît, pour une narration/cinématique d'ouverture. " "RÈGLE IMPORTANTE : si le créateur ne précise PAS explicitement quel " - "déclencheur utiliser pour un élément donné (ex. \"ajoute un panneau " + 'déclencheur utiliser pour un élément donné (ex. "ajoute un panneau ' "d'information\" sans dire si c'est au clic, au survol, ou dès " "l'affichage), NE CHOISIS PAS toute seule — pose la question dans ta " "réponse texte avant d'agir. " "NOUVELLES ACTIONS (utilisables partout où une feuille est attendue, " - "chaînables via \"then\" comme dialogue/variable) : \"surbrillance\" " + 'chaînables via "then" comme dialogue/variable) : "surbrillance" ' "(met un objet en valeur, ex. pour guider l'attention du joueur vers " - "la suite), \"visibilite\" (affiche/masque un objet, ex. débloquer un " - "élément), \"son\" (effet sonore ponctuel), \"video\" (joue une vidéo " - "de \"Mes assets\", en plein écran ou en incrustation — bloque la " - "suite de la chaîne jusqu'à la fin, comme un dialogue), \"indication\" " + 'la suite), "visibilite" (affiche/masque un objet, ex. débloquer un ' + 'élément), "son" (effet sonore ponctuel), "video" (joue une vidéo ' + 'de "Mes assets", en plein écran ou en incrustation — bloque la ' + 'suite de la chaîne jusqu\'à la fin, comme un dialogue), "indication" ' "(bulle de texte courte près d'un objet, pour un conseil ponctuel — " - "PAS pour une réplique de personnage, utilise \"dialogue\" pour ça), " - "\"attendre\" (suspend la chaîne \"then\" pendant data_value SECONDES " + 'PAS pour une réplique de personnage, utilise "dialogue" pour ça), ' + '"attendre" (suspend la chaîne "then" pendant data_value SECONDES ' "avant de continuer — utile pour laisser un temps de lecture après " "une narration à l'affichage de l'écran, avant d'enchaîner sur un " "dialogue/une autre action)." @@ -97,13 +99,13 @@ _SYSTEM_PROMPT = ( "VISIBLE (coin haut-gauche à (0,0)) — place tout objet important " "(personnages, widgets d'interface) DANS cette zone par défaut. Le " "moteur RAMÈNE automatiquement dans le cadre toute position qui en " - "sortirait (voir un éventuel champ \"note\" dans le résultat de " + 'sortirait (voir un éventuel champ "note" dans le résultat de ' "set_object_geometry/add_scene_object — c'est déjà corrigé, rien à " "refaire), donc vise une position raisonnable sans stresser sur le " "pixel exact. Ne superpose jamais deux objets aux mêmes coordonnées " "— espace-les clairement. Un personnage fraîchement posé fait " "128x128 px par défaut — garde des tailles cohérentes entre " - "personnages sauf besoin explicite. RÈGLE FIXE pour un \"fond\" : " + 'personnages sauf besoin explicite. RÈGLE FIXE pour un "fond" : ' "redimensionne-le TOUJOURS automatiquement en 2000x1000 px " "(set_object_geometry, position (0,0)) juste après l'avoir posé, " "SANS que le créateur ait besoin de le demander à chaque fois — " @@ -112,37 +114,37 @@ _SYSTEM_PROMPT = ( "grand." "\n\n" "JOUEUR ET PNJ : un personnage fraîchement posé a TOUJOURS le rôle " - "\"pnj\" par défaut, JAMAIS \"joueur\". Un déclencheur de type " - "\"collision\" ne se déclenche QUE par le contact du personnage au " - "rôle \"joueur\" — sans lui, la collision ne se déclenche jamais ET " + '"pnj" par défaut, JAMAIS "joueur". Un déclencheur de type ' + '"collision" ne se déclenche QUE par le contact du personnage au ' + 'rôle "joueur" — sans lui, la collision ne se déclenche jamais ET ' "la caméra n'a personne à suivre. Dès qu'un écran a besoin d'un " "déclencheur de collision (quiz, dialogue déclenché en marchant " "vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN " - "personnage avec role=\"joueur\" (set_object_role) — jamais deux, " + 'personnage avec role="joueur" (set_object_role) — jamais deux, ' "et ne pose jamais deux PNJ identiques sans que le créateur l'ait " "demandé. " "RÈGLE IMPORTANTE : si le créateur ne précise pas comment un " - "déclencheur \"collision\" doit se comporter, demande-lui si " + 'déclencheur "collision" doit se comporter, demande-lui si ' "l'action doit se déclencher IMMÉDIATEMENT au contact, ou si le " - "joueur doit d'abord APPUYER SUR UNE TOUCHE (action \"interagir\", " - "qui affiche \"Appuie sur [touche]\" tant que le contact dure) avant " + 'joueur doit d\'abord APPUYER SUR UNE TOUCHE (action "interagir", ' + 'qui affiche "Appuie sur [touche]" tant que le contact dure) avant ' "que l'action ne se déclenche — ne suppose jamais l'un ou l'autre " "toi-même sur une scène avec joueur+collision." "\n\n" "QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une " "donnée consultée plus tard (progression, un choix du joueur, un " - "drapeau \"terminé\") — JAMAIS pour un score de quiz (voir " + 'drapeau "terminé") — JAMAIS pour un score de quiz (voir ' "reward_amount plus haut, déjà automatique). Modifie une variable " - "(action \"variable\") quand un événement doit changer durablement " - "cet état (ex. marquer un drapeau \"xxx_termine\" à vrai une fois un " + '(action "variable") quand un événement doit changer durablement ' + 'cet état (ex. marquer un drapeau "xxx_termine" à vrai une fois un ' "quiz fini). Utilise une CONDITION quand le comportement doit " "VRAIMENT différer selon l'état actuel d'une variable — un simple " "enchaînement linéaire n'a besoin que d'un chaînage \"then\", jamais " "d'une condition. CAS CANONIQUE à connaître : un dialogue/quiz à " "USAGE UNIQUE (ex. un PNJ qui pose un quiz une seule fois) doit être " "protégé par une CONDITION qui vérifie D'ABORD le drapeau " - "\"xxx_termine\" — si faux (pas encore fait), lance le dialogue/quiz " - "normal (branche si_faux) PUIS termine par une action \"variable\" " + '"xxx_termine" — si faux (pas encore fait), lance le dialogue/quiz ' + 'normal (branche si_faux) PUIS termine par une action "variable" ' "qui passe ce drapeau à vrai ; si vrai (déjà fait), réponds par une " "réplique courte différente (branche si_vrai) au lieu de rejouer " "tout le quiz à chaque collision. Utilise un DIALOGUE pour toute " @@ -152,31 +154,31 @@ _SYSTEM_PROMPT = ( "JEU : il faut TOUJOURS poser en plus le widget d'interface " "correspondant sur l'écran via add_scene_object, sinon rien n'apparaît " "à l'écran même si le déclencheur est correctement configuré. Une " - "action \"dialogue\" dont les lignes sont de type \"replique\" a besoin " - "d'un objet kind=\"dialogue_box\" ; une action \"dialogue\" dont les " - "lignes sont de type \"question\" (quiz) a besoin d'un objet " - "kind=\"quiz_box\" (pas dialogue_box) ; si un score/des points sont " - "utilisés, ajoute aussi un objet kind=\"score_widget\" pour qu'il soit " + 'action "dialogue" dont les lignes sont de type "replique" a besoin ' + 'd\'un objet kind="dialogue_box" ; une action "dialogue" dont les ' + 'lignes sont de type "question" (quiz) a besoin d\'un objet ' + 'kind="quiz_box" (pas dialogue_box) ; si un score/des points sont ' + 'utilisés, ajoute aussi un objet kind="score_widget" pour qu\'il soit ' "visible en continu. Positionne ces widgets à un endroit raisonnable " "de l'écran (ex. centré, ou en haut) via set_object_geometry après " "les avoir posés." "\n\n" "QUIZ AUTONOME (RH/formation, pas narratif) : quand le créateur décrit " - "un besoin de quiz \"tout seul\" (pas un personnage/dialogue de jeu), " - "utilise set_quiz_box_config sur l'objet kind=\"quiz_box\" pour régler " + 'un besoin de quiz "tout seul" (pas un personnage/dialogue de jeu), ' + 'utilise set_quiz_box_config sur l\'objet kind="quiz_box" pour régler ' "le PLEIN ÉCRAN (fullscreen) et un MINUTEUR pour répondre (timer_mode : " "jamais imposé par défaut — demande TOUJOURS si le créateur en veut un " - "avant d'en activer un, \"question\" redémarre à chaque question, " - "\"quiz\" est un seul compte à rebours pour tout le quiz, timer_seconds " + 'avant d\'en activer un, "question" redémarre à chaque question, ' + '"quiz" est un seul compte à rebours pour tout le quiz, timer_seconds ' "sa durée)." "\n\n" "MODÈLES VISUELS — DEUX catégories bien distinctes, jamais confondues : " - "dialog_template (modèles \"boîte de dialogue\" : \"defaut\" — Classique — " - "ou \"manga_dialogue\", le pendant en petite carte du thème manga) ne " + 'dialog_template (modèles "boîte de dialogue" : "defaut" — Classique — ' + 'ou "manga_dialogue", le pendant en petite carte du thème manga) ne ' "s'affiche QUE si fullscreen=false. " - "page_template (modèles \"page de quiz\" : \"classique\" — sobre/" - "professionnel, couleurs reprises du modèle \"boîte de dialogue\" de " - "base — ou \"manga\", un thème dessiné/typographié entièrement à part) " + 'page_template (modèles "page de quiz" : "classique" — sobre/' + 'professionnel, couleurs reprises du modèle "boîte de dialogue" de ' + 'base — ou "manga", un thème dessiné/typographié entièrement à part) ' "ne s'affiche QUE si fullscreen=true — chacun un thème complet et " "autonome (police, formes, couleurs ET structure entièrement propres à " "ce modèle), pensé pour un quiz qui occupe tout l'écran. Les deux " @@ -194,13 +196,26 @@ _SYSTEM_PROMPT = ( ) -def _describe_scene_state(slug, screen_id): +class ScreenDeletedError(Exception): + """Garde-fou défensif pour screen_id introuvable : en usage normal, + supprimer un écran (screens/screens_repo/delete_screen.py) supprime + déjà EN CASCADE ses conversations IA (ON DELETE CASCADE, voir + screens/ia/ensure_ia_chat_schema.py) — cette voie n'est donc pas + censée être atteignable via l'appli. get_screen() renvoyant + dict | None, ce garde évite quand même un crash cru (attribut sur + None) si jamais screen_id était invalide pour une autre raison, + plutôt qu'un message lisible par un humain (voir run_chat_turn).""" + + +def _describe_scene_state(slug: str, screen_id: int) -> str: """Contexte dynamique (jamais mémorisé côté Claude entre les tours, voir run_chat_turn — seul le texte final est persisté) : sans ça, Ruby ne "voit" jamais ce qui existe déjà sur l'écran et duplique des objets au lieu de les corriger (bug observé : 2 PNJ identiques créés à la place d'un seul joueur + un pnj).""" screen = screens.get_screen(slug, screen_id) + if screen is None: + raise ScreenDeletedError("L'écran de cette conversation a été supprimé.") width, height = screen["scene_width"], screen["scene_height"] objects = screens.list_scene_objects(slug, screen_id) # Répété ICI (pas seulement dans les instructions générales) avec les @@ -251,11 +266,11 @@ def _describe_scene_state(slug, screen_id): return "\n".join(lines) -def _history_to_messages(history): +def _history_to_messages(history: list[dict[str, Any]]) -> list[Any]: return [{"role": m["role"], "content": m["content"]} for m in history] -def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message): +def run_chat_turn(slug: str, screen_id: int, conversation_id: int, user_id: int, user_message: str) -> str: """Un tour complet : reprend l'historique persisté de CETTE conversation, ajoute le message du créateur, boucle tant que Claude appelle des outils, et renvoie le texte final. `screen_id` reste @@ -264,40 +279,57 @@ def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message): d'écran). Ne persiste RIEN elle-même — voir routes/ia/ia_chat.py, seul appelant, qui décide de ce qui est sauvegardé (même séparation que le reste du moteur : cette fonction ne fait que la logique IA).""" + try: + # État réel de la scène RE-LU à chaque tour (jamais mémorisé par + # Claude lui-même) — voir _describe_scene_state. Vérifié AVANT de + # construire le client Claude : pas la peine d'appeler l'API si + # l'écran de cette conversation n'existe plus. + scene_state = _describe_scene_state(slug, screen_id) + except ScreenDeletedError: + return "Cet écran a été supprimé — cette conversation n'est plus utilisable." client = get_client() # AnthropicNotConfiguredError si pas de clé - messages = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id)) + messages: list[Any] = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id)) messages.append({"role": "user", "content": user_message}) - # État réel de la scène RE-LU à chaque tour (jamais mémorisé par - # Claude lui-même) — voir _describe_scene_state. - system_prompt = _SYSTEM_PROMPT + "\n\n" + _describe_scene_state(slug, screen_id) + system_prompt = _SYSTEM_PROMPT + "\n\n" + scene_state response = None for _ in range(_MAX_TOOL_ITERATIONS): - response = client.messages.create( - # 4096 était trop bas (bug corrigé) : la réflexion adaptative - # partage le même budget que la réponse — sur une demande - # riche (plusieurs objets + logique + texte), Claude pouvait - # être coupé EN PLEINE RÉFLEXION, avant le moindre appel - # d'outil (symptôme observé : aucune progression du tout, - # "(pas de réponse textuelle)" dès le premier tour). - model=MODEL, max_tokens=16000, system=system_prompt, - tools=TOOLS, thinking={"type": "adaptive"}, messages=messages, + # 4096 était trop bas (bug corrigé) : la réflexion adaptative + # partage le même budget que la réponse — sur une demande riche + # (plusieurs objets + logique + texte), Claude pouvait être coupé + # EN PLEINE RÉFLEXION, avant le moindre appel d'outil (symptôme + # observé : aucune progression du tout, "(pas de réponse + # textuelle)" dès le premier tour). + response = client.messages.create( # type: ignore[call-overload] # TOOLS/messages sont des dict Python bruts, pas les TypedDict exacts du SDK anthropic + model=MODEL, + max_tokens=16000, + system=system_prompt, + tools=TOOLS, + thinking={"type": "adaptive"}, + messages=messages, ) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": break - tool_results = [] + tool_results: list[Any] = [] for block in response.content: if block.type == "tool_use": try: result = dispatch_tool(slug, screen_id, user_id, block.name, block.input) except Exception as e: result = {"error": str(e)} - tool_results.append({ - "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result), - }) + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": json.dumps(result), + } + ) messages.append({"role": "user", "content": tool_results}) + assert response is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction + "_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois" + ) text = next((b.text for b in response.content if b.type == "text"), "") if text: return text diff --git a/ai/client.py b/ai/client.py index 16df7252..cbf4df10 100644 --- a/ai/client.py +++ b/ai/client.py @@ -1,6 +1,7 @@ """Client Claude (voir plan Phase 2, §2) — même politique que auth/send_email.py::EmailNotConfiguredError : une clé absente est une configuration incomplète, jamais un crash brut ni une clé en dur.""" + import os import anthropic @@ -14,10 +15,8 @@ class AnthropicNotConfiguredError(Exception): il faut un compte Anthropic Console (console.anthropic.com) séparé.""" -def get_client(): +def get_client() -> anthropic.Anthropic: api_key = os.environ.get("ANTHROPIC_API_KEY") if not api_key: - raise AnthropicNotConfiguredError( - "ANTHROPIC_API_KEY absente" - ) + raise AnthropicNotConfiguredError("ANTHROPIC_API_KEY absente") return anthropic.Anthropic(api_key=api_key) diff --git a/ai/scenario_client.py b/ai/scenario_client.py index 17d905ac..bb85ee36 100644 --- a/ai/scenario_client.py +++ b/ai/scenario_client.py @@ -9,6 +9,7 @@ Ce module reste un simple client HTTP : il renvoie l'URL de l'image, jamais ne la télécharge/l'enregistre lui-même — ça reste le travail de ai/tools.py::_dispatch_add_generated_image (voir "Mes assets", auth/user_assets_dir.py), pour que ce module soit testable seul.""" + import os import time @@ -28,7 +29,7 @@ class ScenarioGenerationError(Exception): """Le job Scenario a échoué ou n'a pas répondu à temps.""" -def _credentials(): +def _credentials() -> tuple[tuple[str, str], str]: api_key = os.environ.get("SCENARIO_API_KEY") api_secret = os.environ.get("SCENARIO_API_SECRET") model_id = os.environ.get("SCENARIO_MODEL_ID") @@ -39,14 +40,18 @@ def _credentials(): return (api_key, api_secret), model_id -def generate_image_url(prompt, width=768, height=768, timeout=120, poll_interval=2): +def generate_image_url( + prompt: str, width: int = 768, height: int = 768, timeout: int = 120, poll_interval: int = 2 +) -> str: """Lance une génération txt2img et attend le résultat — renvoie l'URL finale de l'image (hébergée par Scenario, à télécharger par l'appelant). Bloquant (poll_interval secondes entre chaque vérification), borné par `timeout` secondes au total.""" auth, model_id = _credentials() response = requests.post( - f"{_BASE_URL}/generate/txt2img", auth=auth, timeout=30, + f"{_BASE_URL}/generate/txt2img", + auth=auth, + timeout=30, json={"prompt": prompt, "modelId": model_id, "width": width, "height": height, "numSamples": 1}, ) response.raise_for_status() @@ -61,7 +66,7 @@ def generate_image_url(prompt, width=768, height=768, timeout=120, poll_interval asset_id = job["metadata"]["assetIds"][0] asset_response = requests.get(f"{_BASE_URL}/assets/{asset_id}", auth=auth, timeout=30) asset_response.raise_for_status() - return asset_response.json()["asset"]["url"] + return str(asset_response.json()["asset"]["url"]) if job["status"] == "failure": raise ScenarioGenerationError("Scenario a échoué à générer l'image.") time.sleep(poll_interval) diff --git a/ai/tools.py b/ai/tools.py index 794e3549..618d55c4 100644 --- a/ai/tools.py +++ b/ai/tools.py @@ -20,6 +20,7 @@ DATA_OPERATION_LABELS, ...) plutôt que recopiées en dur. Un test dédié remplacée par une copie littérale.""" import os +from typing import Any, Callable import requests from flask import url_for @@ -28,12 +29,13 @@ import auth import db import screens from db.dialogue_lines import QUESTION_REWARD_TYPES -from screens.rendering.collision_rules import ACTION_TYPES, LEAF_ACTION_TYPES, CONDITION_OPERATOR_KEYS, TRIGGER_TYPES -from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN from screens.labels.data_operations import DATA_OPERATION_LABELS from screens.labels.element_visibility import ELEMENT_VISIBILITY_LABELS from screens.labels.surbrillance_values import SURBRILLANCE_LABELS from screens.labels.video_modes import VIDEO_MODE_LABELS +from screens.rendering.collision_rules import ACTION_TYPES, CONDITION_OPERATOR_KEYS, LEAF_ACTION_TYPES, TRIGGER_TYPES +from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN + from .scenario_client import generate_image_url _CONDITION_OPERATORS = sorted(CONDITION_OPERATOR_KEYS) @@ -53,37 +55,49 @@ _GLOBAL_VARIABLE_TYPES = sorted(db.GLOBAL_VARIABLE_TYPES) _FORGE_CHARACTERS = sorted(screens.SPRITE_LIBRARY) _BACKGROUNDS = sorted(screens.BACKGROUND_LIBRARY) -_LEAF_ACTION_SCHEMA = { +_LEAF_ACTION_SCHEMA: dict[str, Any] = { "type": "object", "description": ( - "Une action FEUILLE (\"dialogue\" ou \"variable\", voir " + 'Une action FEUILLE ("dialogue" ou "variable", voir ' "screens/rendering/collision_rules.py) — peut porter un champ " - "optionnel \"then\" (une autre feuille, chaînage borné à 4)." + 'optionnel "then" (une autre feuille, chaînage borné à 4).' ), "properties": { "type": {"type": "string", "enum": list(LEAF_ACTION_TYPES)}, - "id": {"type": "string", "description": "Identifiant de cette feuille, pour l'adresser plus tard via append_action_to_trigger."}, + "id": { + "type": "string", + "description": "Identifiant de cette feuille, pour l'adresser plus tard via append_action_to_trigger.", + }, "dialogue": { "type": "object", "description": ( "Pour type=dialogue : {id, lines}. Chaque élément de `lines` est SOIT une " - "RÉPLIQUE {\"type\":\"dialogue\", \"speaker\": , \"text\": }, " - "SOIT une QUESTION DE QUIZ {\"type\":\"question\", \"text\", \"choices\": " - "[2 à 4 réponses], \"correct_index\": , " - f"\"reward_type\": {list(QUESTION_REWARD_TYPES)!r}, \"reward_amount\": }}. " + 'RÉPLIQUE {"type":"dialogue", "speaker": , "text": }, ' + 'SOIT une QUESTION DE QUIZ {"type":"question", "text", "choices": ' + '[2 à 4 réponses], "correct_index": , ' + f'"reward_type": {list(QUESTION_REWARD_TYPES)!r}, "reward_amount": }}. ' "reward_amount alimente le SCORE NATIF du jeu (visible via un objet " - "kind=\"score_widget\", nécessite aussi un objet kind=\"quiz_box\" posé pour que " + 'kind="score_widget", nécessite aussi un objet kind="quiz_box" posé pour que ' "la question s'affiche) — crédité UNIQUEMENT si la réponse est correcte, sinon " "ignoré ; le joueur avance toujours à la ligne suivante, bonne réponse ou pas. " "N'invente JAMAIS une variable séparée pour suivre un score de quiz : " - "reward_amount fait déjà tout, sans action \"variable\" supplémentaire." + 'reward_amount fait déjà tout, sans action "variable" supplémentaire.' ), "properties": {"id": {"type": "string"}, "lines": {"type": "array"}}, }, "mark_completed": {"type": "boolean"}, - "target_variable": {"type": "string", "description": "Pour type=variable : nom d'une variable globale existante."}, + "target_variable": { + "type": "string", + "description": "Pour type=variable : nom d'une variable globale existante.", + }, "data_operation": {"type": "string", "enum": _DATA_OPERATIONS}, - "data_value": {"type": "string", "description": "Pour type=variable : absent pour definir_bool_vrai/definir_bool_faux/basculer_bool. Pour type=attendre : nombre de SECONDES à attendre avant \"then\" (ex. \"2\", \"1.5\")."}, + "data_value": { + "type": "string", + "description": ( + "Pour type=variable : absent pour definir_bool_vrai/definir_bool_faux/basculer_bool. " + 'Pour type=attendre : nombre de SECONDES à attendre avant "then" (ex. "2", "1.5").' + ), + }, "object_id": { "type": "integer", "description": ( @@ -102,7 +116,7 @@ _LEAF_ACTION_SCHEMA = { }, "asset_url": { "type": "string", - "description": "Pour type=son/video : URL d'un fichier déjà présent dans \"Mes assets\".", + "description": 'Pour type=son/video : URL d\'un fichier déjà présent dans "Mes assets".', }, "mode": {"type": "string", "enum": sorted(VIDEO_MODE_LABELS), "description": "Pour type=video."}, "texte": {"type": "string", "description": "Pour type=indication : le texte affiché dans la bulle."}, @@ -112,9 +126,11 @@ _LEAF_ACTION_SCHEMA = { "required": ["type"], } -_ACTION_SCHEMA = { +_ACTION_SCHEMA: dict[str, Any] = { "type": "object", - "description": "Une action de déclencheur — voir screens/rendering/collision_rules.py pour la forme exacte de chaque type.", + "description": ( + "Une action de déclencheur — voir screens/rendering/collision_rules.py pour la forme exacte de chaque type." + ), "properties": { "type": {"type": "string", "enum": list(ACTION_TYPES)}, "id": {"type": "string"}, @@ -129,8 +145,12 @@ _ACTION_SCHEMA = { "mode": _LEAF_ACTION_SCHEMA["properties"]["mode"], "texte": _LEAF_ACTION_SCHEMA["properties"]["texte"], "duree_ms": _LEAF_ACTION_SCHEMA["properties"]["duree_ms"], - "then": {"description": "Feuille suivante (dialogue/variable/surbrillance/visibilite/son/video/indication), récursif."}, - "sub_action": {"description": "Pour type=interagir : une action (pas \"interagir\" à nouveau)."}, + "then": { + "description": ( + "Feuille suivante (dialogue/variable/surbrillance/visibilite/son/video/indication), récursif." + ), + }, + "sub_action": {"description": 'Pour type=interagir : une action (pas "interagir" à nouveau).'}, "variable": {"type": "string", "description": "Pour type=condition."}, "operateur": {"type": "string", "enum": _CONDITION_OPERATORS}, "si_vrai": {"description": "Pour type=condition : null ou une feuille."}, @@ -139,21 +159,43 @@ _ACTION_SCHEMA = { "required": ["type"], } -TOOLS = [ +TOOLS: list[dict[str, Any]] = [ { "name": "add_scene_object", "description": ( "Pose un nouvel objet sur l'écran en cours (personnage, décor, fond, " - "ou widget d'interface). Pour une image générée/de \"Mes assets\", " + 'ou widget d\'interface). Pour une image générée/de "Mes assets", ' "utiliser image_url (jamais générer un personnage/sprite par ce biais)." ), "input_schema": { "type": "object", "properties": { - "kind": {"type": "string", "enum": ["personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widget"]}, - "forge_character": {"type": "string", "enum": _FORGE_CHARACTERS, "description": "Pour kind=personnage : slug d'un personnage de la bibliothèque Forge existante (jamais un sprite généré)."}, - "background_slug": {"type": "string", "enum": _BACKGROUNDS, "description": "Pour kind=fond : slug d'une image de fond déjà existante dans la bibliothèque Forge."}, - "image_url": {"type": "string", "description": "URL d'une image déjà uploadée/générée (voir \"Mes assets\") — kind decor/fond uniquement, prioritaire sur background_slug."}, + "kind": { + "type": "string", + "enum": ["personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widget"], + }, + "forge_character": { + "type": "string", + "enum": _FORGE_CHARACTERS, + "description": ( + "Pour kind=personnage : slug d'un personnage de la bibliothèque Forge " + "existante (jamais un sprite généré)." + ), + }, + "background_slug": { + "type": "string", + "enum": _BACKGROUNDS, + "description": ( + "Pour kind=fond : slug d'une image de fond déjà existante dans la bibliothèque Forge." + ), + }, + "image_url": { + "type": "string", + "description": ( + 'URL d\'une image déjà uploadée/générée (voir "Mes assets") — kind ' + "decor/fond uniquement, prioritaire sur background_slug." + ), + }, }, "required": ["kind"], }, @@ -165,8 +207,10 @@ TOOLS = [ "type": "object", "properties": { "object_id": {"type": "integer"}, - "x": {"type": "number"}, "y": {"type": "number"}, - "width": {"type": "number"}, "height": {"type": "number"}, + "x": {"type": "number"}, + "y": {"type": "number"}, + "width": {"type": "number"}, + "height": {"type": "number"}, }, "required": ["object_id", "x", "y", "width", "height"], }, @@ -174,8 +218,8 @@ TOOLS = [ { "name": "set_object_name", "description": ( - "Donne un nom à un objet de scène (ex. \"Stan\", \"Aka\") — affiché comme " - "\"qui parle\" dans les dialogues qui lui sont attachés." + 'Donne un nom à un objet de scène (ex. "Stan", "Aka") — affiché comme ' + '"qui parle" dans les dialogues qui lui sont attachés.' ), "input_schema": { "type": "object", @@ -207,8 +251,10 @@ TOOLS = [ "object_id": {"type": "integer"}, "enabled": {"type": "boolean"}, "shape": {"type": "string", "enum": _COLLISION_SHAPES}, - "width": {"type": "number"}, "height": {"type": "number"}, - "offset_x": {"type": "number"}, "offset_y": {"type": "number"}, + "width": {"type": "number"}, + "height": {"type": "number"}, + "offset_x": {"type": "number"}, + "offset_y": {"type": "number"}, }, "required": ["object_id"], }, @@ -216,13 +262,13 @@ TOOLS = [ { "name": "set_quiz_box_config", "description": ( - "Règle les options d'une \"❓ Boîte à quiz\" pensées pour un quiz AUTONOME " - "(RH/formation) : plein écran, minuteur (jamais imposé — \"aucun\" par défaut, " - "au choix du créateur : \"question\" redémarre à chaque question, \"quiz\" est un " + 'Règle les options d\'une "❓ Boîte à quiz" pensées pour un quiz AUTONOME ' + '(RH/formation) : plein écran, minuteur (jamais imposé — "aucun" par défaut, ' + 'au choix du créateur : "question" redémarre à chaque question, "quiz" est un ' "seul compte à rebours pour tout le quiz), et DEUX modèles visuels INDÉPENDANTS " "(les deux réglages sont conservés en même temps, jamais l'un n'écrase l'autre) : " "dialog_template (visible SEULEMENT hors plein écran — variantes de forme/couleur " - "sur une structure proche de \"Classique\") et page_template (visible SEULEMENT en " + 'sur une structure proche de "Classique") et page_template (visible SEULEMENT en ' "plein écran — thème complet et autonome, structure HTML propre à chaque modèle). " "En plein écran, le score se retrouve affiché directement dans la boîte." ), @@ -232,16 +278,29 @@ TOOLS = [ "object_id": {"type": "integer"}, "fullscreen": {"type": "boolean"}, "timer_mode": {"type": "string", "enum": _QUIZ_BOX_TIMER_MODES}, - "timer_seconds": {"type": "integer", "description": "Durée du minuteur (3 à 600s), ignorée si timer_mode=\"aucun\"."}, - "dialog_template": {"type": "string", "enum": _QUIZ_BOX_DIALOG_TEMPLATES, "description": "Modèle \"boîte de dialogue\" — ne s'affiche que si fullscreen=false."}, - "page_template": {"type": "string", "enum": _QUIZ_BOX_PAGE_TEMPLATES, "description": "Modèle \"page de quiz\" — ne s'affiche que si fullscreen=true."}, + "timer_seconds": { + "type": "integer", + "description": 'Durée du minuteur (3 à 600s), ignorée si timer_mode="aucun".', + }, + "dialog_template": { + "type": "string", + "enum": _QUIZ_BOX_DIALOG_TEMPLATES, + "description": 'Modèle "boîte de dialogue" — ne s\'affiche que si fullscreen=false.', + }, + "page_template": { + "type": "string", + "enum": _QUIZ_BOX_PAGE_TEMPLATES, + "description": 'Modèle "page de quiz" — ne s\'affiche que si fullscreen=true.', + }, }, "required": ["object_id"], }, }, { "name": "create_global_variable", - "description": "Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions.", + "description": ( + "Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions." + ), "input_schema": { "type": "object", "properties": { @@ -256,10 +315,10 @@ TOOLS = [ { "name": "set_collision_rules", "description": ( - "Remplace TOUTES les règles \"déclencheur -> action\" d'un objet. " - "Déclencheurs disponibles : \"collision\" (contact avec le personnage " - "\"joueur\" — voir set_object_role), \"clic\" (l'objet est cliqué/touché, " - "aucun joueur requis), \"survol\" (le pointeur survole l'objet, aucun " + 'Remplace TOUTES les règles "déclencheur -> action" d\'un objet. ' + 'Déclencheurs disponibles : "collision" (contact avec le personnage ' + '"joueur" — voir set_object_role), "clic" (l\'objet est cliqué/touché, ' + 'aucun joueur requis), "survol" (le pointeur survole l\'objet, aucun ' "joueur requis). Sanitizé côté serveur (screens.sanitize_collision_rules) : " "toute valeur invalide est silencieusement retirée plutôt que rejetée — " "compare le nombre de `rules` renvoyées dans le résultat à ce que tu as " @@ -316,7 +375,7 @@ TOOLS = [ "description": ( "Génère une image (fond ou objet 2D UNIQUEMENT, JAMAIS un " "personnage/sprite — la génération d'image n'est pas fiable pour " - "ça) via Scenario, l'ajoute à \"Mes assets\" et la pose " + 'ça) via Scenario, l\'ajoute à "Mes assets" et la pose ' "automatiquement sur l'écran en cours." ), "input_schema": { @@ -370,9 +429,9 @@ TOOLS = [ "description": ( "Retire UNE action précise (leaf_id) d'un déclencheur d'OBJET, où " "qu'elle soit dans l'arbre (feuille directe, maillon d'une chaîne " - "\"then\", sous-action d'un \"interagir\", ou branche d'une " - "\"condition\"). Si c'était la SEULE action de la règle, la règle " - "entière disparaît ; si elle avait une suite \"then\", celle-ci " + '"then", sous-action d\'un "interagir", ou branche d\'une ' + '"condition"). Si c\'était la SEULE action de la règle, la règle ' + 'entière disparaît ; si elle avait une suite "then", celle-ci ' "prend sa place." ), "input_schema": { @@ -397,7 +456,7 @@ TOOLS = [ "name": "move_trigger_action", "description": ( "Déplace une action précise (leaf_id) d'un cran vers le haut " - "(\"up\") ou le bas (\"down\") DANS SA PROPRE CHAÎNE \"then\", pour " + '("up") ou le bas ("down") DANS SA PROPRE CHAÎNE "then", pour ' "un déclencheur d'OBJET — jamais au-delà de sa branche de " "condition/sous-action interagir." ), @@ -426,7 +485,9 @@ TOOLS = [ ] -def _clamp_to_camera(slug, screen_id, kind, x, y, width, height): +def _clamp_to_camera( + slug: str, screen_id: int, kind: str | None, x: float, y: float, width: float, height: float +) -> tuple[float, float, str | None]: """Le placement précis par coordonnées s'est montré peu fiable pour l'IA malgré des instructions explicites ET un avertissement dans le résultat de l'outil (bug observé à répétition — l'IA n'arrivait @@ -456,7 +517,15 @@ def _clamp_to_camera(slug, screen_id, kind, x, y, width, height): return clamped_x, clamped_y, note -def _dispatch_add_scene_object(slug, screen_id, user_id, kind, forge_character=None, background_slug=None, image_url=None): +def _dispatch_add_scene_object( + slug: str, + screen_id: int, + user_id: int, + kind: str, + forge_character: str | None = None, + background_slug: str | None = None, + image_url: str | None = None, +) -> dict[str, Any]: # Même garde que la galerie manuelle (core/sprite_gate.py) — un # compte non-admin ne doit pas pouvoir poser un sprite/fond # admin-only via l'IA alors que la galerie de l'éditeur ne les lui @@ -481,59 +550,90 @@ def _dispatch_add_scene_object(slug, screen_id, user_id, kind, forge_character=N if background_slug and background_slug not in screens.BACKGROUND_LIBRARY: raise ValueError(f"background_slug inconnu : {background_slug!r} (voir la liste enum du tool)") object_id = screens.add_scene_object( - slug, screen_id, kind=kind, forge_character=forge_character, - background_slug=background_slug, image_url=image_url, + slug, + screen_id, + kind=kind, + forge_character=forge_character, + background_slug=background_slug, + image_url=image_url, ) obj = screens.get_scene_object(slug, object_id) + assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction + "object_id vient d'etre cree par screens.add_scene_object juste au-dessus" + ) # Position par défaut (100,100, voir ensure_scene_schema.py) déjà # posée par screens.add_scene_object — reste passée par le même # garde-fou pour rester correcte même si ce défaut changeait un jour. - clamped_x, clamped_y, note = _clamp_to_camera(slug, screen_id, kind, obj["x"], obj["y"], obj["width"], obj["height"]) + clamped_x, clamped_y, note = _clamp_to_camera( + slug, screen_id, kind, obj["x"], obj["y"], obj["width"], obj["height"] + ) if note: screens.update_scene_object_geometry(slug, object_id, clamped_x, clamped_y, obj["width"], obj["height"]) - result = {"object_id": object_id} + result: dict[str, Any] = {"object_id": object_id} if note: result["note"] = note return result -def _dispatch_set_object_geometry(slug, screen_id, user_id, object_id, x, y, width, height): +def _dispatch_set_object_geometry( + slug: str, screen_id: int, user_id: int, object_id: int, x: float, y: float, width: float, height: float +) -> dict[str, Any]: obj = screens.get_scene_object(slug, object_id) x, y, note = _clamp_to_camera(slug, screen_id, obj["kind"] if obj else None, x, y, width, height) screens.update_scene_object_geometry(slug, object_id, x, y, width, height) - result = {"ok": True} + result: dict[str, Any] = {"ok": True} if note: result["note"] = note return result -def _dispatch_set_object_name(slug, screen_id, user_id, object_id, name): +def _dispatch_set_object_name(slug: str, screen_id: int, user_id: int, object_id: int, name: str) -> dict[str, Any]: screens.set_scene_object_name(slug, object_id, name) return {"ok": True} -def _dispatch_set_object_role(slug, screen_id, user_id, object_id, role): +def _dispatch_set_object_role(slug: str, screen_id: int, user_id: int, object_id: int, role: str) -> dict[str, Any]: screens.set_scene_object_role(slug, object_id, role) return {"ok": True} -def _dispatch_set_object_collision(slug, screen_id, user_id, object_id, enabled=True, shape="rectangle", - width=None, height=None, offset_x=0, offset_y=0): +def _dispatch_set_object_collision( + slug: str, + screen_id: int, + user_id: int, + object_id: int, + enabled: bool = True, + shape: str = "rectangle", + width: float | None = None, + height: float | None = None, + offset_x: float = 0, + offset_y: float = 0, +) -> dict[str, Any]: # Même forme que routes/scenes/scene_object_collision.py (remplacement # complet des réglages, jamais un merge partiel). - settings = { + settings: dict[str, Any] = { "enabled": bool(enabled), "shape": shape if shape in _COLLISION_SHAPES else "rectangle", - "width": width, "height": height, - "offset_x": offset_x or 0, "offset_y": offset_y or 0, + "width": width, + "height": height, + "offset_x": offset_x or 0, + "offset_y": offset_y or 0, } screens.set_scene_object_collision(slug, object_id, settings) return {"ok": True} -def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscreen=False, - timer_mode="aucun", timer_seconds=20, - dialog_template="defaut", page_template="classique"): +def _dispatch_set_quiz_box_config( + slug: str, + screen_id: int, + user_id: int, + object_id: int, + fullscreen: bool = False, + timer_mode: str = "aucun", + timer_seconds: int = 20, + dialog_template: str = "defaut", + page_template: str = "classique", +) -> dict[str, Any]: # Même forme que screens.set_scene_object_quiz_config (remplacement # complet des réglages, jamais un merge partiel) — screens.py sanitize # de toute façon toute valeur hors énumération, ce garde-fou ici sert @@ -541,7 +641,7 @@ def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscree # dialog_template/page_template sont conservés INDÉPENDAMMENT (voir # screens/rendering/quiz_box_config.py) : Ruby peut en régler un seul # sans jamais écraser l'autre. - config = { + config: dict[str, Any] = { "fullscreen": bool(fullscreen), "timer_mode": timer_mode if timer_mode in _QUIZ_BOX_TIMER_MODES else "aucun", "timer_seconds": timer_seconds or 20, @@ -552,54 +652,76 @@ def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscree return {"ok": True} -def _dispatch_create_global_variable(slug, screen_id, user_id, name, var_type="texte", default_value="", per_player=True): - variable_id = db.create_global_variable(slug, name, var_type=var_type, default_value=default_value, per_player=per_player) +def _dispatch_create_global_variable( + slug: str, + screen_id: int, + user_id: int, + name: str, + var_type: str = "texte", + default_value: str = "", + per_player: bool = True, +) -> dict[str, Any]: + variable_id = db.create_global_variable( + slug, name, var_type=var_type, default_value=default_value, per_player=per_player + ) return {"variable_id": variable_id} -def _dispatch_set_collision_rules(slug, screen_id, user_id, object_id, rules): +def _dispatch_set_collision_rules( + slug: str, screen_id: int, user_id: int, object_id: int, rules: Any +) -> dict[str, Any]: sanitized = screens.sanitize_collision_rules(rules) screens.set_scene_object_collision_rules(slug, object_id, sanitized) return {"ok": True, "rules": sanitized} -def _dispatch_append_action_to_trigger(slug, screen_id, user_id, object_id, after_id, action): +def _dispatch_append_action_to_trigger( + slug: str, screen_id: int, user_id: int, object_id: int, after_id: str, action: dict[str, Any] +) -> dict[str, Any]: ok = screens.append_action_to_trigger(slug, object_id, after_id, action) return {"ok": ok} -def _dispatch_set_screen_triggers(slug, screen_id, user_id, rules): +def _dispatch_set_screen_triggers(slug: str, screen_id: int, user_id: int, rules: Any) -> dict[str, Any]: sanitized = screens.sanitize_screen_triggers(rules) screens.set_screen_triggers(slug, screen_id, sanitized) return {"ok": True, "rules": sanitized} -def _dispatch_append_action_to_screen_trigger(slug, screen_id, user_id, after_id, action): +def _dispatch_append_action_to_screen_trigger( + slug: str, screen_id: int, user_id: int, after_id: str, action: dict[str, Any] +) -> dict[str, Any]: ok = screens.append_action_to_screen_trigger(slug, screen_id, after_id, action) return {"ok": ok} -def _dispatch_remove_trigger_action(slug, screen_id, user_id, object_id, leaf_id): +def _dispatch_remove_trigger_action( + slug: str, screen_id: int, user_id: int, object_id: int, leaf_id: str +) -> dict[str, Any]: ok = screens.remove_action_from_trigger(slug, object_id, leaf_id) return {"ok": ok} -def _dispatch_remove_screen_trigger_action(slug, screen_id, user_id, leaf_id): +def _dispatch_remove_screen_trigger_action(slug: str, screen_id: int, user_id: int, leaf_id: str) -> dict[str, Any]: ok = screens.remove_action_from_screen_trigger(slug, screen_id, leaf_id) return {"ok": ok} -def _dispatch_move_trigger_action(slug, screen_id, user_id, object_id, leaf_id, direction): +def _dispatch_move_trigger_action( + slug: str, screen_id: int, user_id: int, object_id: int, leaf_id: str, direction: str +) -> dict[str, Any]: ok = screens.move_action_in_trigger(slug, object_id, leaf_id, direction) return {"ok": ok} -def _dispatch_move_screen_trigger_action(slug, screen_id, user_id, leaf_id, direction): +def _dispatch_move_screen_trigger_action( + slug: str, screen_id: int, user_id: int, leaf_id: str, direction: str +) -> dict[str, Any]: ok = screens.move_action_in_screen_trigger(slug, screen_id, leaf_id, direction) return {"ok": ok} -def _dispatch_add_generated_image(slug, screen_id, user_id, kind, prompt): +def _dispatch_add_generated_image(slug: str, screen_id: int, user_id: int, kind: str, prompt: str) -> dict[str, Any]: """Scenario -> "Mes assets" (auth.save_user_asset, source="ia") -> posée sur l'écran via LE MÊME chemin que Phase 1 (screens.add_scene_object(image_url=...)) — jamais un chemin @@ -615,7 +737,7 @@ def _dispatch_add_generated_image(slug, screen_id, user_id, kind, prompt): return {"object_id": object_id, "asset_id": asset_id} -_DISPATCH = { +_DISPATCH: dict[str, Callable[..., dict[str, Any]]] = { "add_scene_object": _dispatch_add_scene_object, "set_object_geometry": _dispatch_set_object_geometry, "set_object_name": _dispatch_set_object_name, @@ -635,7 +757,9 @@ _DISPATCH = { } -def dispatch_tool(slug, screen_id, user_id, tool_name, tool_input): +def dispatch_tool( + slug: str, screen_id: int, user_id: int, tool_name: str, tool_input: dict[str, Any] +) -> dict[str, Any]: """Point d'entrée UNIQUE utilisé par la boucle tool-use (Phase 2) — lève KeyError pour un nom d'outil inconnu (jamais silencieux : un tool annoncé par TOOLS mais absent d'ici serait un bug de ce module, pas diff --git a/app.py b/app.py index c7322838..b04a1d3c 100644 --- a/app.py +++ b/app.py @@ -15,19 +15,23 @@ Lance un serveur web local. Fonctionnalités construites pour l'instant une relation existante. """ +import os import threading import webbrowser -from core.flask_app import app -from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja import routes # noqa: F401 - enregistre toutes les routes sur `app` -from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes) -from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja -from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF -from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja +from core import ( + auth_guard, # noqa: F401 - enregistre la garde de connexion (après les routes) + csrf, # noqa: F401 - enregistre csrf_token() comme variable globale Jinja + csrf_guard, # noqa: F401 - enregistre la vérification du jeton CSRF + db_teardown_guard, # noqa: F401 - enregistre la fermeture des connexions SQLite fuitées + jinja_filters, # noqa: F401 - enregistre les filtres Jinja + recovery_codes_flash, # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja +) +from core.flask_app import app -def _open_browser(): +def _open_browser() -> None: webbrowser.open("http://127.0.0.1:5050/") @@ -41,4 +45,9 @@ if __name__ == "__main__": # l'éditeur de scène "vide" le temps que les images finissent par # arriver l'une après l'autre — un rechargement normal, servi surtout # depuis le cache navigateur, le cachait). - app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False, threaded=True) + # debug : jamais actif par defaut (Bandit B201 - le debogueur Werkzeug + # permet l'execution de code arbitraire) — activable en local via + # FORGE_DEBUG=1 dans .env, jamais utilise en production (gunicorn y + # sert app:app directement, ce bloc __main__ n'y tourne pas). + debug = os.environ.get("FORGE_DEBUG") == "1" + app.run(host="127.0.0.1", port=5050, debug=debug, use_reloader=False, threaded=True) diff --git a/auth/__init__.py b/auth/__init__.py index 0f34d761..3630b741 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -4,63 +4,94 @@ create_user.py pour le détail des règles. Base SQLite entièrement séparée de db/ (une base par JEU) : ces comptes n'appartiennent à aucun jeu, ils en POSSÈDENT un (project_slug).""" +from .confirm_totp import confirm_totp from .connection import users_db_path +from .count_admins import count_admins +from .create_user import UserCreationError, create_user +from .create_user_asset import create_user_asset +from .delete_user import delete_user +from .delete_user_asset import delete_user_asset from .ensure_schema import ensure_users_schema -from .is_first_user import is_first_user -from .create_user import create_user, UserCreationError +from .ensure_user_assets_schema import ensure_user_assets_schema +from .get_user_asset import get_user_asset from .get_user_by_email import get_user_by_email from .get_user_by_id import get_user_by_id -from .verify_password import verify_password -from .confirm_totp import confirm_totp -from .verify_totp import verify_totp -from .set_project_slug import set_project_slug -from .password_strength import password_strength, MIN_SCORE_REQUIRED -from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg -from .rate_limit import lockout_minutes_for, seconds_locked_remaining, lockout_message -from .record_failed_attempt import record_failed_attempt -from .reset_failed_attempts import reset_failed_attempts -from .recovery_codes import generate_recovery_codes, verify_recovery_code -from .set_password import set_password -from .password_reset import ( - create_password_reset_token, get_user_id_for_valid_token, consume_password_reset_token, - TOKEN_TTL_MINUTES, -) -from .send_email import send_password_reset_email, EmailNotConfiguredError -from .update_profile import update_profile -from .count_admins import count_admins -from .delete_user import delete_user -from .update_email import update_email, EmailUpdateError -from .ensure_user_assets_schema import ensure_user_assets_schema -from .user_assets_dir import user_assets_dir -from .create_user_asset import create_user_asset -from .list_user_assets import list_user_assets -from .get_user_asset import get_user_asset -from .delete_user_asset import delete_user_asset -from .save_user_asset import save_user_asset -from .user_asset_kind import user_asset_kind -from .validate_audio_duration import validate_audio_duration, MAX_AUDIO_SECONDS -from .validate_video_duration import validate_video_duration, MAX_VIDEO_SECONDS from .image_dimensions import image_dimensions +from .is_first_user import is_first_user +from .list_user_assets import list_user_assets +from .password_reset import ( + TOKEN_TTL_MINUTES, + consume_password_reset_token, + create_password_reset_token, + get_user_id_for_valid_token, +) +from .password_strength import MIN_SCORE_REQUIRED, password_strength +from .rate_limit import lockout_message, lockout_minutes_for, seconds_locked_remaining +from .record_failed_attempt import record_failed_attempt +from .recovery_codes import generate_recovery_codes, verify_recovery_code +from .reset_failed_attempts import reset_failed_attempts +from .save_user_asset import save_user_asset +from .send_email import EmailNotConfiguredError, send_password_reset_email +from .set_password import set_password +from .set_project_slug import set_project_slug +from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg +from .update_email import EmailUpdateError, update_email +from .update_profile import update_profile from .update_user_asset_scene_kind import update_user_asset_scene_kind +from .user_asset_kind import user_asset_kind +from .user_assets_dir import user_assets_dir +from .validate_audio_duration import MAX_AUDIO_SECONDS, validate_audio_duration +from .validate_video_duration import MAX_VIDEO_SECONDS, validate_video_duration +from .verify_password import verify_password +from .verify_totp import verify_totp __all__ = [ - "users_db_path", "ensure_users_schema", "is_first_user", - "create_user", "UserCreationError", - "get_user_by_email", "get_user_by_id", - "verify_password", "confirm_totp", "verify_totp", "set_project_slug", - "password_strength", "MIN_SCORE_REQUIRED", - "totp_provisioning_uri", "totp_qrcode_svg", - "lockout_minutes_for", "seconds_locked_remaining", "lockout_message", - "record_failed_attempt", "reset_failed_attempts", - "generate_recovery_codes", "verify_recovery_code", - "set_password", "create_password_reset_token", "get_user_id_for_valid_token", - "consume_password_reset_token", "TOKEN_TTL_MINUTES", - "send_password_reset_email", "EmailNotConfiguredError", - "update_profile", "count_admins", "delete_user", - "update_email", "EmailUpdateError", - "ensure_user_assets_schema", "user_assets_dir", "create_user_asset", - "list_user_assets", "get_user_asset", "delete_user_asset", "save_user_asset", "user_asset_kind", - "validate_audio_duration", "MAX_AUDIO_SECONDS", - "validate_video_duration", "MAX_VIDEO_SECONDS", - "image_dimensions", "update_user_asset_scene_kind", + "users_db_path", + "ensure_users_schema", + "is_first_user", + "create_user", + "UserCreationError", + "get_user_by_email", + "get_user_by_id", + "verify_password", + "confirm_totp", + "verify_totp", + "set_project_slug", + "password_strength", + "MIN_SCORE_REQUIRED", + "totp_provisioning_uri", + "totp_qrcode_svg", + "lockout_minutes_for", + "seconds_locked_remaining", + "lockout_message", + "record_failed_attempt", + "reset_failed_attempts", + "generate_recovery_codes", + "verify_recovery_code", + "set_password", + "create_password_reset_token", + "get_user_id_for_valid_token", + "consume_password_reset_token", + "TOKEN_TTL_MINUTES", + "send_password_reset_email", + "EmailNotConfiguredError", + "update_profile", + "count_admins", + "delete_user", + "update_email", + "EmailUpdateError", + "ensure_user_assets_schema", + "user_assets_dir", + "create_user_asset", + "list_user_assets", + "get_user_asset", + "delete_user_asset", + "save_user_asset", + "user_asset_kind", + "validate_audio_duration", + "MAX_AUDIO_SECONDS", + "validate_video_duration", + "MAX_VIDEO_SECONDS", + "image_dimensions", + "update_user_asset_scene_kind", ] diff --git a/auth/confirm_totp.py b/auth/confirm_totp.py index 7d91b14a..c7448990 100644 --- a/auth/confirm_totp.py +++ b/auth/confirm_totp.py @@ -1,7 +1,7 @@ from .connection import connect -def confirm_totp(user_id): +def confirm_totp(user_id: int) -> None: conn = connect() conn.execute("UPDATE _users SET totp_confirmed = 1 WHERE id = ?", (user_id,)) conn.commit() diff --git a/auth/connection.py b/auth/connection.py index 4b5ee4f0..8275804d 100644 --- a/auth/connection.py +++ b/auth/connection.py @@ -8,6 +8,7 @@ tests/conftest.py positionne FORGE_USERS_DB_PATH vers un fichier temporaire avant de faire tourner la suite, pour ne jamais toucher à la vraie base de comptes (data/users.db) ni dépendre d'un état "premier compte = admin" déjà consommé par un run précédent.""" + import os import sqlite3 @@ -15,11 +16,11 @@ _BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEFAULT_USERS_DB_PATH = os.path.join(_BASE_DIR, "data", "users.db") -def users_db_path(): +def users_db_path() -> str: return os.environ.get("FORGE_USERS_DB_PATH") or DEFAULT_USERS_DB_PATH -def connect(): +def connect() -> sqlite3.Connection: path = users_db_path() os.makedirs(os.path.dirname(path), exist_ok=True) conn = sqlite3.connect(path, timeout=10) diff --git a/auth/count_admins.py b/auth/count_admins.py index 43647438..80bd60ae 100644 --- a/auth/count_admins.py +++ b/auth/count_admins.py @@ -1,8 +1,8 @@ from .connection import connect -def count_admins(): +def count_admins() -> int: conn = connect() - n = conn.execute("SELECT COUNT(*) AS n FROM _users WHERE role = 'admin'").fetchone()["n"] + n = int(conn.execute("SELECT COUNT(*) AS n FROM _users WHERE role = 'admin'").fetchone()["n"]) conn.close() return n diff --git a/auth/create_user.py b/auth/create_user.py index 3016e39b..0bd91f7d 100644 --- a/auth/create_user.py +++ b/auth/create_user.py @@ -1,9 +1,9 @@ import pyotp from werkzeug.security import generate_password_hash -from .ensure_schema import ensure_users_schema from .connection import connect from .email_validation import is_valid_email +from .ensure_schema import ensure_users_schema from .get_user_by_email import get_user_by_email from .is_first_user import is_first_user from .password_strength import password_strength @@ -14,7 +14,7 @@ class UserCreationError(Exception): dans le formulaire d'inscription) — jamais un détail SQL/technique.""" -def create_user(email, password, nom, prenom): +def create_user(email: str | None, password: str | None, nom: str | None, prenom: str | None) -> int: """Crée un compte : mot de passe fort (auth/password_strength.py) et 2FA (TOTP) rendus obligatoires — le secret est généré ici mais totp_confirmed reste à 0 tant que confirm_totp() n'a pas vérifié un @@ -26,6 +26,7 @@ def create_user(email, password, nom, prenom): email = (email or "").strip().lower() nom = (nom or "").strip() prenom = (prenom or "").strip() + password = password or "" if not is_valid_email(email): raise UserCreationError("Adresse email invalide.") if not nom or not prenom: @@ -44,7 +45,7 @@ def create_user(email, password, nom, prenom): VALUES (?, ?, ?, ?, ?, ?, 0)""", (email, generate_password_hash(password), nom, prenom, role, totp_secret), ) - user_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + user_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]) conn.commit() conn.close() return user_id diff --git a/auth/create_user_asset.py b/auth/create_user_asset.py index 1b6f2a9d..6c0054de 100644 --- a/auth/create_user_asset.py +++ b/auth/create_user_asset.py @@ -2,7 +2,13 @@ from .connection import connect from .ensure_user_assets_schema import ensure_user_assets_schema -def create_user_asset(user_id, filename, original_name=None, source="upload", scene_kind=None): +def create_user_asset( + user_id: int, + filename: str, + original_name: str | None = None, + source: str = "upload", + scene_kind: str | None = None, +) -> int: """Enregistre une ligne "Mes assets" pour un fichier déjà écrit sur disque (voir user_assets_dir.py) — le fichier lui-même est écrit par l'appelant (route d'upload, ou plus tard la génération IA), cette @@ -21,7 +27,7 @@ def create_user_asset(user_id, filename, original_name=None, source="upload", sc "INSERT INTO _user_assets (user_id, filename, original_name, source, scene_kind) VALUES (?, ?, ?, ?, ?)", (user_id, filename, original_name, source, scene_kind), ) - asset_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + asset_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]) conn.commit() conn.close() return asset_id diff --git a/auth/delete_user.py b/auth/delete_user.py index 85801ea4..3d9cad61 100644 --- a/auth/delete_user.py +++ b/auth/delete_user.py @@ -1,7 +1,7 @@ from .connection import connect -def delete_user(user_id): +def delete_user(user_id: int) -> None: """Supprime le compte et tout ce qui lui est directement rattaché (codes de récupération, jetons de réinitialisation encore en cours) — le dossier de projet, lui, est géré par l'appelant (voir diff --git a/auth/delete_user_asset.py b/auth/delete_user_asset.py index 22fc8bb4..df50e6d9 100644 --- a/auth/delete_user_asset.py +++ b/auth/delete_user_asset.py @@ -5,16 +5,14 @@ from .ensure_user_assets_schema import ensure_user_assets_schema from .user_assets_dir import user_assets_dir -def delete_user_asset(asset_id, user_id): +def delete_user_asset(asset_id: int, user_id: int) -> bool: """N'efface que si asset_id APPARTIENT à user_id (jamais fournie par l'appelant sans vérification — voir get_user_asset.py) : renvoie False sans rien faire sinon, plutôt que de lever une erreur, même patron que screens.delete_scene_object pour un id introuvable.""" ensure_user_assets_schema() conn = connect() - row = conn.execute( - "SELECT filename FROM _user_assets WHERE id = ? AND user_id = ?", (asset_id, user_id) - ).fetchone() + row = conn.execute("SELECT filename FROM _user_assets WHERE id = ? AND user_id = ?", (asset_id, user_id)).fetchone() if not row: conn.close() return False diff --git a/auth/email_validation.py b/auth/email_validation.py index 48afcbfd..bd416b53 100644 --- a/auth/email_validation.py +++ b/auth/email_validation.py @@ -3,5 +3,5 @@ import re EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") -def is_valid_email(email): +def is_valid_email(email: str | None) -> bool: return bool(EMAIL_RE.match((email or "").strip())) diff --git a/auth/ensure_schema.py b/auth/ensure_schema.py index 9d631891..348a6f44 100644 --- a/auth/ensure_schema.py +++ b/auth/ensure_schema.py @@ -1,7 +1,7 @@ from .connection import connect -def ensure_users_schema(): +def ensure_users_schema() -> None: conn = connect() conn.executescript( """ diff --git a/auth/ensure_user_assets_schema.py b/auth/ensure_user_assets_schema.py index 70d37a67..ae5bacd8 100644 --- a/auth/ensure_user_assets_schema.py +++ b/auth/ensure_user_assets_schema.py @@ -1,8 +1,8 @@ from .connection import connect -def ensure_user_assets_schema(): - """"Mes assets" (voir user_assets_dir.py) : une image appartient à un +def ensure_user_assets_schema() -> None: + """ "Mes assets" (voir user_assets_dir.py) : une image appartient à un COMPTE, jamais à un projet — table dans la base de comptes partagée (auth/connection.py), pas dans le game.db d'un jeu (db/connection.py), pour rester utilisable d'un projet à l'autre du même compte.""" diff --git a/auth/get_user_asset.py b/auth/get_user_asset.py index cffad34e..a777dd10 100644 --- a/auth/get_user_asset.py +++ b/auth/get_user_asset.py @@ -1,8 +1,10 @@ +from typing import Any + from .connection import connect from .ensure_user_assets_schema import ensure_user_assets_schema -def get_user_asset(asset_id): +def get_user_asset(asset_id: int) -> dict[str, Any] | None: """Renvoie aussi user_id — indispensable pour vérifier la PROPRIÉTÉ avant de servir/poser un asset (voir routes/assets/serve_user_asset.py, routes/scenes/scene_object_add.py), même esprit que l'isolation par diff --git a/auth/get_user_by_email.py b/auth/get_user_by_email.py index c7519875..586f2614 100644 --- a/auth/get_user_by_email.py +++ b/auth/get_user_by_email.py @@ -1,8 +1,10 @@ -from .ensure_schema import ensure_users_schema +from typing import Any + from .connection import connect +from .ensure_schema import ensure_users_schema -def get_user_by_email(email): +def get_user_by_email(email: str | None) -> dict[str, Any] | None: ensure_users_schema() conn = connect() row = conn.execute("SELECT * FROM _users WHERE email = ?", ((email or "").strip().lower(),)).fetchone() diff --git a/auth/get_user_by_id.py b/auth/get_user_by_id.py index f95014c6..6e86ef3e 100644 --- a/auth/get_user_by_id.py +++ b/auth/get_user_by_id.py @@ -1,8 +1,10 @@ -from .ensure_schema import ensure_users_schema +from typing import Any + from .connection import connect +from .ensure_schema import ensure_users_schema -def get_user_by_id(user_id): +def get_user_by_id(user_id: int | None) -> dict[str, Any] | None: ensure_users_schema() if not user_id: return None diff --git a/auth/image_dimensions.py b/auth/image_dimensions.py index 5c5b2c7c..01072e40 100644 --- a/auth/image_dimensions.py +++ b/auth/image_dimensions.py @@ -1,8 +1,7 @@ -import re import struct -def image_dimensions(content): +def image_dimensions(content: bytes) -> tuple[int, int] | None: """Largeur/hauteur RÉELLES (en pixels) d'une image, lues directement dans ses en-têtes — SANS dépendance externe (Pillow serait la solution habituelle, mais un simple parsing des formats courants suffit ici et @@ -32,7 +31,7 @@ def image_dimensions(content): return None -def _webp_dimensions(content): +def _webp_dimensions(content: bytes) -> tuple[int, int] | None: chunk = content[12:16] if chunk == b"VP8X" and len(content) >= 30: width = 1 + (content[24] | (content[25] << 8) | (content[26] << 16)) @@ -50,7 +49,7 @@ def _webp_dimensions(content): return None -def _jpeg_dimensions(content): +def _jpeg_dimensions(content: bytes) -> tuple[int, int] | None: i = 2 n = len(content) while i + 9 < n: @@ -63,9 +62,9 @@ def _jpeg_dimensions(content): continue if marker == 0xD9: break - seg_len = struct.unpack(">H", content[i + 2:i + 4])[0] + seg_len = struct.unpack(">H", content[i + 2 : i + 4])[0] if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): - height, width = struct.unpack(">HH", content[i + 5:i + 9]) + height, width = struct.unpack(">HH", content[i + 5 : i + 9]) return width, height i += 2 + seg_len return None diff --git a/auth/is_first_user.py b/auth/is_first_user.py index cc73ef6d..6f52e931 100644 --- a/auth/is_first_user.py +++ b/auth/is_first_user.py @@ -1,8 +1,8 @@ -from .ensure_schema import ensure_users_schema from .connection import connect +from .ensure_schema import ensure_users_schema -def is_first_user(): +def is_first_user() -> bool: """True s'il n'existe encore AUCUN compte — le tout premier compte créé devient automatiquement admin (voir create_user.py), pour ne jamais avoir besoin d'un mot de passe par défaut ou d'un script de bootstrap @@ -11,4 +11,4 @@ def is_first_user(): conn = connect() count = conn.execute("SELECT COUNT(*) AS c FROM _users").fetchone()["c"] conn.close() - return count == 0 + return bool(count == 0) diff --git a/auth/list_user_assets.py b/auth/list_user_assets.py index 8876046f..3de37b74 100644 --- a/auth/list_user_assets.py +++ b/auth/list_user_assets.py @@ -1,9 +1,11 @@ +from typing import Any + from .connection import connect from .ensure_user_assets_schema import ensure_user_assets_schema -def list_user_assets(user_id): - """"Mes assets" d'un compte, plus récent en premier — voir +def list_user_assets(user_id: int) -> list[dict[str, Any]]: + """ "Mes assets" d'un compte, plus récent en premier — voir templates/scene_edit.html, bloc "Mes assets" du panneau d'ajout d'élément.""" ensure_user_assets_schema() diff --git a/auth/password_reset.py b/auth/password_reset.py index 7b7c7661..b4d9b273 100644 --- a/auth/password_reset.py +++ b/auth/password_reset.py @@ -7,11 +7,11 @@ from .connection import connect TOKEN_TTL_MINUTES = 60 -def _hash_token(token): +def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() -def create_password_reset_token(user_id): +def create_password_reset_token(user_id: int) -> str: """Un seul jeton valide à la fois par utilisateur : en créer un nouveau invalide silencieusement tout jeu envoyé plus tôt (par exemple si l'utilisateur redemande un email parce que le premier @@ -29,7 +29,7 @@ def create_password_reset_token(user_id): return token -def get_user_id_for_valid_token(token): +def get_user_id_for_valid_token(token: str | None) -> int | None: if not token: return None conn = connect() @@ -42,10 +42,10 @@ def get_user_id_for_valid_token(token): return None if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc): return None - return row["user_id"] + return int(row["user_id"]) -def consume_password_reset_token(token): +def consume_password_reset_token(token: str) -> None: conn = connect() conn.execute( "UPDATE _password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?", diff --git a/auth/password_strength.py b/auth/password_strength.py index 6ea9c3af..9b27a7a2 100644 --- a/auth/password_strength.py +++ b/auth/password_strength.py @@ -1,11 +1,12 @@ import re +from typing import Any, Callable # Mêmes règles des deux côtés (ici pour le refus serveur, en JS dans # templates/auth/register.html pour le schéma visuel qui guide la saisie # en temps réel) — un mot de passe REFUSÉ côté serveur doit toujours # correspondre à une jauge/coche déjà rouge côté client, jamais une # surprise après coup. -_CHECKS = [ +_CHECKS: list[tuple[str, str, Callable[[str], bool]]] = [ ("longueur", "Au moins 8 caractères", lambda p: len(p) >= 8), ("majuscule", "Une majuscule", lambda p: re.search(r"[A-Z]", p) is not None), ("minuscule", "Une minuscule", lambda p: re.search(r"[a-z]", p) is not None), @@ -19,7 +20,7 @@ _CHECKS = [ MIN_SCORE_REQUIRED = 4 -def password_strength(password): +def password_strength(password: str | None) -> dict[str, Any]: """Renvoie {"score": 0-5, "checks": [{"key","label","ok"}, ...], "valid": bool} — jamais None, un mot de passe vide obtient juste un score de 0 (toutes les règles échouent), pas une erreur.""" diff --git a/auth/rate_limit.py b/auth/rate_limit.py index 2467ef86..66797da8 100644 --- a/auth/rate_limit.py +++ b/auth/rate_limit.py @@ -3,24 +3,26 @@ que soit l'étape attaquée) : 3 essais libres, puis un temps d'attente qui double à chaque échec supplémentaire (5 min, 10, 20, 40...), plafonné à 1h. Remis à zéro dès une connexion réussie (voir reset_failed_attempts).""" + import math -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone +from typing import Any _FREE_ATTEMPTS = 3 _FIRST_LOCKOUT_MINUTES = 5 _MAX_LOCKOUT_MINUTES = 60 -def lockout_minutes_for(failed_attempts): +def lockout_minutes_for(failed_attempts: int) -> int: """0 tant qu'on est dans les 3 essais libres ; sinon 5 * 2^n, plafonné à 60 — jamais négatif, jamais None.""" if failed_attempts <= _FREE_ATTEMPTS: return 0 exponent = failed_attempts - _FREE_ATTEMPTS - 1 - return min(_FIRST_LOCKOUT_MINUTES * (2 ** exponent), _MAX_LOCKOUT_MINUTES) + return min(int(_FIRST_LOCKOUT_MINUTES * (2**exponent)), _MAX_LOCKOUT_MINUTES) -def _parse(dt_str): +def _parse(dt_str: str | None) -> datetime | None: if not dt_str: return None try: @@ -29,7 +31,7 @@ def _parse(dt_str): return None -def lockout_message(seconds_remaining): +def lockout_message(seconds_remaining: float) -> str: """Message affiché tel quel dans le formulaire — arrondi à la minute supérieure (jamais "0 minute" alors qu'il en reste un peu, jamais un compte de secondes qui oblige à recharger sans arrêt pour voir si @@ -39,7 +41,7 @@ def lockout_message(seconds_remaining): return f"Trop de tentatives. Réessaie dans {minutes} {unit}." -def seconds_locked_remaining(user): +def seconds_locked_remaining(user: dict[str, Any] | None) -> int: """> 0 si le compte est actuellement verrouillé (temps restant, en secondes, arrondi au-dessus pour ne jamais afficher "0 minute" alors qu'il en reste réellement un peu) ; 0 sinon.""" diff --git a/auth/record_failed_attempt.py b/auth/record_failed_attempt.py index 46b79ef5..287e44fe 100644 --- a/auth/record_failed_attempt.py +++ b/auth/record_failed_attempt.py @@ -4,7 +4,7 @@ from .connection import connect from .rate_limit import lockout_minutes_for -def record_failed_attempt(user_id): +def record_failed_attempt(user_id: int) -> int: """Incrémente le compteur d'échecs de CE compte et, une fois passé les 3 essais libres, pose/allonge son verrouillage (voir rate_limit.py) — appelé sur un mot de passe OU un code 2FA incorrect, jamais sur un diff --git a/auth/recovery_codes.py b/auth/recovery_codes.py index 7c6b8799..a28ba382 100644 --- a/auth/recovery_codes.py +++ b/auth/recovery_codes.py @@ -7,11 +7,11 @@ from .connection import connect _CODE_COUNT = 10 -def _format_code(raw): - return "-".join(raw[i:i + 4] for i in range(0, len(raw), 4)) +def _format_code(raw: str) -> str: + return "-".join(raw[i : i + 4] for i in range(0, len(raw), 4)) -def generate_recovery_codes(user_id): +def generate_recovery_codes(user_id: int) -> list[str]: """(Re)génère les codes de récupération 2FA d'un utilisateur : un nouvel appel invalide tout jeu de codes précédent (un seul jeu valide à la fois, pour ne jamais avoir à deviner lesquels tiennent encore). @@ -21,7 +21,7 @@ def generate_recovery_codes(user_id): directement réutilisables.""" conn = connect() conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,)) - codes = [] + codes: list[str] = [] for _ in range(_CODE_COUNT): code = _format_code(secrets.token_hex(6)) codes.append(code) @@ -34,7 +34,7 @@ def generate_recovery_codes(user_id): return codes -def verify_recovery_code(user_id, code): +def verify_recovery_code(user_id: int, code: str | None) -> bool: """Un code n'est utilisable qu'UNE SEULE FOIS (used_at) : consommé dès qu'il sert à une connexion réussie, pour qu'un code intercepté une fois (capture d'écran, historique du navigateur...) ne redonne pas un diff --git a/auth/reset_failed_attempts.py b/auth/reset_failed_attempts.py index 057ff22e..0f10a986 100644 --- a/auth/reset_failed_attempts.py +++ b/auth/reset_failed_attempts.py @@ -1,7 +1,7 @@ from .connection import connect -def reset_failed_attempts(user_id): +def reset_failed_attempts(user_id: int) -> None: """Remet le compteur à zéro et lève tout verrouillage — appelé dès qu'une étape de connexion réussit (mot de passe validé ou code 2FA validé), pour ne jamais punir un utilisateur légitime qui s'est juste diff --git a/auth/save_user_asset.py b/auth/save_user_asset.py index f2643036..b926964f 100644 --- a/auth/save_user_asset.py +++ b/auth/save_user_asset.py @@ -5,7 +5,14 @@ from .create_user_asset import create_user_asset from .user_assets_dir import user_assets_dir -def save_user_asset(user_id, content, ext, original_name=None, source="upload", scene_kind=None): +def save_user_asset( + user_id: int, + content: bytes, + ext: str, + original_name: str | None = None, + source: str = "upload", + scene_kind: str | None = None, +) -> tuple[int, str]: """Écrit `content` (bytes) sur disque + crée la ligne "Mes assets" — factorisé pour être partagé par l'upload manuel (routes/assets/upload_user_asset.py) ET la génération IA diff --git a/auth/send_email.py b/auth/send_email.py index 3c9c97ae..44cd3ab6 100644 --- a/auth/send_email.py +++ b/auth/send_email.py @@ -4,6 +4,7 @@ Python. Configuré uniquement par variables d'environnement (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) : ce module ne connaît et ne stocke jamais le mot de passe SMTP en dur, à définir sur le poste/ serveur qui fait tourner l'appli.""" + import os import smtplib from email.mime.text import MIMEText @@ -19,7 +20,7 @@ class EmailNotConfiguredError(Exception): qu'il doit finir de configurer son SMTP.""" -def send_email(to_email, subject, body): +def send_email(to_email: str, subject: str, body: str) -> None: host = os.environ.get("SMTP_HOST") port = int(os.environ.get("SMTP_PORT", "587")) user = os.environ.get("SMTP_USER") @@ -40,7 +41,7 @@ def send_email(to_email, subject, body): server.sendmail(sender, [to_email], msg.as_string()) -def send_password_reset_email(to_email, reset_url): +def send_password_reset_email(to_email: str, reset_url: str) -> None: body = ( "Une réinitialisation de mot de passe a été demandée pour ce compte " "Forge Engine.\n\n" diff --git a/auth/set_password.py b/auth/set_password.py index 88b1cc72..cc3ed640 100644 --- a/auth/set_password.py +++ b/auth/set_password.py @@ -3,7 +3,7 @@ from werkzeug.security import generate_password_hash from .connection import connect -def set_password(user_id, new_password): +def set_password(user_id: int, new_password: str) -> None: """Remet aussi le compteur anti-bruteforce à zéro (auth/rate_limit.py) : prouver son identité par email est une voie de récupération légitime, un compte verrouillé après trop d'échecs ne doit pas rester bloqué une diff --git a/auth/set_project_slug.py b/auth/set_project_slug.py index dcb84fd6..eb42a68f 100644 --- a/auth/set_project_slug.py +++ b/auth/set_project_slug.py @@ -1,7 +1,7 @@ from .connection import connect -def set_project_slug(user_id, slug): +def set_project_slug(user_id: int, slug: str) -> None: """Enregistre le SEUL projet que ce compte possède (voir create_user.py — un compte "user" n'en aura jamais qu'un ; un compte "admin" n'a PAS besoin de cette colonne, il reste libre de créer diff --git a/auth/totp_qrcode_svg.py b/auth/totp_qrcode_svg.py index 337ab2a0..b4211cfc 100644 --- a/auth/totp_qrcode_svg.py +++ b/auth/totp_qrcode_svg.py @@ -5,11 +5,11 @@ import qrcode import qrcode.image.svg -def totp_provisioning_uri(secret, email): - return pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine") +def totp_provisioning_uri(secret: str, email: str) -> str: + return str(pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine")) -def totp_qrcode_svg(secret, email): +def totp_qrcode_svg(secret: str, email: str) -> str: """SVG (pas PNG) : la variante "image factory" par défaut de qrcode a besoin de Pillow pour produire un PNG — SvgPathImage, elle, est du pur Python, sans dépendance supplémentaire à installer juste pour un QR @@ -30,4 +30,4 @@ def totp_qrcode_svg(secret, email): buf = io.BytesIO() img.save(buf) svg = buf.getvalue().decode("utf-8") - return svg[svg.index(" str: new_email = (new_email or "").strip().lower() if not is_valid_email(new_email): raise EmailUpdateError("Adresse email invalide.") diff --git a/auth/update_profile.py b/auth/update_profile.py index ad9619da..a4354b53 100644 --- a/auth/update_profile.py +++ b/auth/update_profile.py @@ -1,7 +1,7 @@ from .connection import connect -def update_profile(user_id, nom, prenom): +def update_profile(user_id: int, nom: str, prenom: str) -> None: conn = connect() conn.execute("UPDATE _users SET nom = ?, prenom = ? WHERE id = ?", (nom, prenom, user_id)) conn.commit() diff --git a/auth/update_user_asset_scene_kind.py b/auth/update_user_asset_scene_kind.py index 0ae84492..328073d9 100644 --- a/auth/update_user_asset_scene_kind.py +++ b/auth/update_user_asset_scene_kind.py @@ -4,7 +4,7 @@ from .get_user_asset import get_user_asset from .user_asset_kind import user_asset_kind -def update_user_asset_scene_kind(asset_id, user_id, scene_kind): +def update_user_asset_scene_kind(asset_id: int, user_id: int, scene_kind: str | None) -> bool: """Reclasse une image déjà importée entre "Fonds" et "Décors/objets" (voir create_user_asset.py) — SANS ré-upload (demande implicite, trouvée en diagnostiquant un "décalage" : une image importée AVANT diff --git a/auth/user_asset_kind.py b/auth/user_asset_kind.py index 5e2a6b59..dcfbabc5 100644 --- a/auth/user_asset_kind.py +++ b/auth/user_asset_kind.py @@ -14,7 +14,7 @@ _AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"} _VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi", ".ogv"} -def user_asset_kind(filename): +def user_asset_kind(filename: str) -> str: ext = os.path.splitext(filename)[1].lower() if ext in _IMAGE_EXTENSIONS: return "image" diff --git a/auth/user_assets_dir.py b/auth/user_assets_dir.py index 2d907d63..c5c6afad 100644 --- a/auth/user_assets_dir.py +++ b/auth/user_assets_dir.py @@ -3,7 +3,7 @@ import os from db.constants import USER_ASSETS_DIR -def user_assets_dir(user_id): +def user_assets_dir(user_id: int) -> str: """Dossier disque des images d'un compte (voir ensure_user_assets_schema.py pour les métadonnées) — mirroir de db/game_dir.py, mais indexé par utilisateur plutôt que par slug de projet.""" diff --git a/auth/validate_audio_duration.py b/auth/validate_audio_duration.py index 9539e2f9..f2be9ff6 100644 --- a/auth/validate_audio_duration.py +++ b/auth/validate_audio_duration.py @@ -9,7 +9,7 @@ from mutagen import File as MutagenFile MAX_AUDIO_SECONDS = 180 -def validate_audio_duration(content, max_seconds=MAX_AUDIO_SECONDS): +def validate_audio_duration(content: bytes, max_seconds: int = MAX_AUDIO_SECONDS) -> str | None: """Lit la durée d'un fichier audio (mp3/wav/ogg/m4a/... — mutagen détecte le format lui-même, pas besoin de le lui préciser) et renvoie un message d'erreur (français, prêt à afficher) si elle dépasse diff --git a/auth/validate_video_duration.py b/auth/validate_video_duration.py index 9bf07ca6..b8789e45 100644 --- a/auth/validate_video_duration.py +++ b/auth/validate_video_duration.py @@ -9,7 +9,9 @@ from mutagen import File as MutagenFile MAX_VIDEO_SECONDS = 300 -def validate_video_duration(content, filename="video.mp4", max_seconds=MAX_VIDEO_SECONDS): +def validate_video_duration( + content: bytes, filename: str = "video.mp4", max_seconds: int = MAX_VIDEO_SECONDS +) -> str | None: """Lit la durée d'un fichier vidéo MP4 (seul format accepté à l'upload, voir routes/assets/upload_user_asset.py — mutagen ne sait pas lire fiablement la durée de webm/mov/avi, contrairement au conteneur MP4/ diff --git a/auth/verify_password.py b/auth/verify_password.py index 905a31d7..863fcf54 100644 --- a/auth/verify_password.py +++ b/auth/verify_password.py @@ -1,5 +1,7 @@ +from typing import Any + from werkzeug.security import check_password_hash -def verify_password(user, password): - return bool(user) and check_password_hash(user["password_hash"], password or "") +def verify_password(user: dict[str, Any] | None, password: str | None) -> bool: + return user is not None and bool(check_password_hash(user["password_hash"], password or "")) diff --git a/auth/verify_totp.py b/auth/verify_totp.py index 7dfbe530..5e89570a 100644 --- a/auth/verify_totp.py +++ b/auth/verify_totp.py @@ -1,7 +1,7 @@ import pyotp -def verify_totp(secret, code): +def verify_totp(secret: str, code: str | None) -> bool: """valid_window=1 : tolère un léger décalage d'horloge entre le serveur et le téléphone (accepte aussi le code de la période précédente/suivante, ±30s) — sans quoi une horloge un peu désynchronisée @@ -9,4 +9,4 @@ def verify_totp(secret, code): code = (code or "").strip() if not code: return False - return pyotp.TOTP(secret).verify(code, valid_window=1) + return bool(pyotp.TOTP(secret).verify(code, valid_window=1)) diff --git a/build_css.py b/build_css.py index 9664d216..29c12ca2 100644 --- a/build_css.py +++ b/build_css.py @@ -13,6 +13,7 @@ variables/règles posées par le précédent : Lancer `python build_css.py` après toute modification sous styles/ ou static/vendor/bulma.min.css.""" + import os _BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -27,8 +28,8 @@ _SOURCES = [ ] -def build(): - chunks = [] +def build() -> None: + chunks: list[str] = [] for path in _SOURCES: with open(path, encoding="utf-8") as f: chunks.append(f"/* ---- {os.path.relpath(path, _BASE_DIR)} ---- */\n" + f.read()) diff --git a/core/auth_guard.py b/core/auth_guard.py index ec82e5bc..a862c167 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -11,7 +11,9 @@ pour que `app.url_map` connaisse déjà toutes les routes au moment où ce module tente de résoudre request.endpoint — en pratique sans importance ici (la résolution se fait à la requête, pas à l'import), mais gardé pour rester cohérent avec l'ordre d'import du reste du moteur.""" -from flask import g, redirect, request, session, url_for, abort + +from flask import abort, g, redirect, request, session, url_for +from werkzeug.wrappers import Response import auth from db.games.project_slug import split_slug @@ -22,8 +24,14 @@ from .flask_app import app # session valide. "static" (CSS/JS/images) doit rester public : la page # de connexion elle-même en a besoin pour s'afficher. _PUBLIC_ENDPOINTS = { - "static", "login", "login_2fa", "register", "register_2fa", "logout", - "forgot_password", "reset_password", + "static", + "login", + "login_2fa", + "register", + "register_2fa", + "logout", + "forgot_password", + "reset_password", } # Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion @@ -32,13 +40,18 @@ _PUBLIC_ENDPOINTS = { # quand même pouvoir changer son mot de passe, son email, ou supprimer # son compte. _REACHABLE_WITHOUT_PROJECT = { - "onboarding_new", "profile", "profile_update_name", "profile_update_email", - "profile_regenerate_recovery_codes", "profile_change_password", "profile_delete", + "onboarding_new", + "profile", + "profile_update_name", + "profile_update_email", + "profile_regenerate_recovery_codes", + "profile_change_password", + "profile_delete", } @app.before_request -def _require_login_and_enforce_project_isolation(): +def _require_login_and_enforce_project_isolation() -> Response | None: endpoint = request.endpoint if endpoint is None or endpoint in _PUBLIC_ENDPOINTS: return None diff --git a/core/csrf.py b/core/csrf.py index a925949c..20db03c7 100644 --- a/core/csrf.py +++ b/core/csrf.py @@ -6,6 +6,7 @@ un fetch() construit à la main (screen_edit.html, game_dashboard.html, play.html...) — voir static/csrf_fetch.js, qui l'ajoute automatiquement en en-tête à CHAQUE fetch() non-GET de l'appli plutôt que de devoir modifier individuellement chacun des nombreux appels existants.""" + import secrets from flask import session @@ -13,12 +14,12 @@ from flask import session from .flask_app import app -def get_csrf_token(): +def get_csrf_token() -> str: token = session.get("csrf_token") if not token: token = secrets.token_urlsafe(32) session["csrf_token"] = token - return token + return str(token) app.jinja_env.globals["csrf_token"] = get_csrf_token diff --git a/core/csrf_guard.py b/core/csrf_guard.py index 1a98e185..9c14a2cd 100644 --- a/core/csrf_guard.py +++ b/core/csrf_guard.py @@ -12,6 +12,7 @@ X-CSRFToken sur toute requête non-GET de l'appli (celles de pjax.js comprises) : pas besoin de modifier individuellement les nombreux appels fetch() déjà écrits à la main dans screen_edit.html/game_dashboard.html/ play.html.""" + from flask import abort, request, session from .flask_app import app @@ -20,7 +21,7 @@ _SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} @app.before_request -def _verify_csrf_token(): +def _verify_csrf_token() -> None: if request.method in _SAFE_METHODS: return None if app.config.get("TESTING"): diff --git a/core/db_teardown_guard.py b/core/db_teardown_guard.py new file mode 100644 index 00000000..1de95ff9 --- /dev/null +++ b/core/db_teardown_guard.py @@ -0,0 +1,11 @@ +"""Enregistre le filet de sécurité de fermeture des connexions SQLite +fuitées (voir db/connection.py::install_teardown_safety_net) sur l'app +Flask — vit dans core/ (couche de câblage qui branche les autres paquets +sur l'app), jamais l'inverse : db/ ne doit importer aucun paquet au-dessus +de lui dans la hiérarchie (voir contrat import-linter, pyproject.toml).""" + +from db.connection import install_teardown_safety_net + +from .flask_app import app + +install_teardown_safety_net(app) diff --git a/core/flask_app.py b/core/flask_app.py index 72285248..8e35f85a 100644 --- a/core/flask_app.py +++ b/core/flask_app.py @@ -19,10 +19,11 @@ _BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates") _STATIC_FOLDER = os.path.join(_BASE_DIR, "static") -app = Flask(__name__, template_folder=_TEMPLATE_FOLDER, static_folder=_STATIC_FOLDER) +# CSRF gere par core/csrf_guard.py (garde maison globale, testee dans test_csrf.py) - pas Flask-WTF. +app = Flask(__name__, template_folder=_TEMPLATE_FOLDER, static_folder=_STATIC_FOLDER) # NOSONAR S4502 -def _load_or_create_secret_key(): +def _load_or_create_secret_key() -> str: """Nécessaire dès qu'une session Flask (flask.session) est utilisée — ici pour se souvenir de qui est connecté (auth/). Généré une seule fois et PERSISTÉ dans un fichier (jamais en dur dans le code, jamais diff --git a/core/recovery_codes_flash.py b/core/recovery_codes_flash.py index 02e435ca..e496e714 100644 --- a/core/recovery_codes_flash.py +++ b/core/recovery_codes_flash.py @@ -3,13 +3,15 @@ auth/recovery_codes.py et routes/auth/register_2fa.py) : posés en session au moment de la redirection qui suit leur génération, puis consommés (session.pop) dès le tout premier rendu de base.html qui suit — jamais revus après ce rendu, comme un message flash à usage unique.""" + from flask import session from .flask_app import app -def pop_recovery_codes(): - return session.pop("recovery_codes_to_show", None) +def pop_recovery_codes() -> list[str] | None: + codes = session.pop("recovery_codes_to_show", None) + return list(codes) if codes is not None else None app.jinja_env.globals["pop_recovery_codes"] = pop_recovery_codes diff --git a/core/sprite_gate.py b/core/sprite_gate.py index de8e05b1..97c50d04 100644 --- a/core/sprite_gate.py +++ b/core/sprite_gate.py @@ -6,12 +6,13 @@ screen_edit.py, routes/scenes/scene_edit_view.py) ne les propose déjà pas (element_add, scene_object_add, element_set_personnage_data, scene_object_personnage_data) pourrait contourner ce filtrage d'UI — ce garde-fou ferme cette brèche au moment de l'écriture.""" + from flask import abort, g import screens -def forbid_admin_only_character(forge_character): +def forbid_admin_only_character(forge_character: str | None) -> None: if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and g.current_user["role"] != "admin": abort(403) @@ -21,6 +22,6 @@ def forbid_admin_only_character(forge_character): # voir screens/labels/background_library.py) — TOUJOURS admin-only # aujourd'hui (un seul pack, tous sous licence CraftPix), contrairement # aux personnages Forge/Kenney (domaine public, jamais gatés). -def forbid_admin_only_background(background_slug): +def forbid_admin_only_background(background_slug: str | None) -> None: if background_slug in screens.ADMIN_ONLY_BACKGROUND_SLUGS and g.current_user["role"] != "admin": abort(403) diff --git a/db/__init__.py b/db/__init__.py index c7f0f0fd..23666a38 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -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", ] diff --git a/db/connection.py b/db/connection.py index 21805dae..f48289ae 100644 --- a/db/connection.py +++ b/db/connection.py @@ -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() diff --git a/db/custom_events/create_custom_event.py b/db/custom_events/create_custom_event.py index ce60dbb0..81abf015 100644 --- a/db/custom_events/create_custom_event.py +++ b/db/custom_events/create_custom_event.py @@ -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 diff --git a/db/custom_events/ensure_custom_events_schema.py b/db/custom_events/ensure_custom_events_schema.py index d7bfaaaf..78e70ad6 100644 --- a/db/custom_events/ensure_custom_events_schema.py +++ b/db/custom_events/ensure_custom_events_schema.py @@ -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 — diff --git a/db/custom_events/get_custom_event.py b/db/custom_events/get_custom_event.py index 453deb07..c7b2a466 100644 --- a/db/custom_events/get_custom_event.py +++ b/db/custom_events/get_custom_event.py @@ -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) diff --git a/db/custom_events/list_custom_events.py b/db/custom_events/list_custom_events.py index 624fb80c..c6ed8a1a 100644 --- a/db/custom_events/list_custom_events.py +++ b/db/custom_events/list_custom_events.py @@ -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() diff --git a/db/custom_events/update_custom_event.py b/db/custom_events/update_custom_event.py index b548114d..ff0a9d40 100644 --- a/db/custom_events/update_custom_event.py +++ b/db/custom_events/update_custom_event.py @@ -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 diff --git a/db/db_path.py b/db/db_path.py index 31491d58..8829baff 100644 --- a/db/db_path.py +++ b/db/db_path.py @@ -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") diff --git a/db/definitions/add_field_to_definition.py b/db/definitions/add_field_to_definition.py deleted file mode 100644 index ae2835cd..00000000 --- a/db/definitions/add_field_to_definition.py +++ /dev/null @@ -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() diff --git a/db/definitions/create_definition.py b/db/definitions/create_definition.py index 82b8461e..7430bc27 100644 --- a/db/definitions/create_definition.py +++ b/db/definitions/create_definition.py @@ -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"] diff --git a/db/definitions/definitions_referencing.py b/db/definitions/definitions_referencing.py index 51f7cacf..25d45ec1 100644 --- a/db/definitions/definitions_referencing.py +++ b/db/definitions/definitions_referencing.py @@ -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.""" diff --git a/db/definitions/delete_definition.py b/db/definitions/delete_definition.py index 719e18ed..0d008470 100644 --- a/db/definitions/delete_definition.py +++ b/db/definitions/delete_definition.py @@ -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,)) diff --git a/db/definitions/delete_field.py b/db/definitions/delete_field.py deleted file mode 100644 index a6a4d008..00000000 --- a/db/definitions/delete_field.py +++ /dev/null @@ -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() diff --git a/db/definitions/ensure_field_bounds_schema.py b/db/definitions/ensure_field_bounds_schema.py index 204ea533..9e12db0f 100644 --- a/db/definitions/ensure_field_bounds_schema.py +++ b/db/definitions/ensure_field_bounds_schema.py @@ -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 à diff --git a/db/definitions/get_definition.py b/db/definitions/get_definition.py index c149774d..bc2e10d9 100644 --- a/db/definitions/get_definition.py +++ b/db/definitions/get_definition.py @@ -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() diff --git a/db/definitions/list_definitions.py b/db/definitions/list_definitions.py index 526d501e..ca5c38c9 100644 --- a/db/definitions/list_definitions.py +++ b/db/definitions/list_definitions.py @@ -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() diff --git a/db/definitions/rename_definition.py b/db/definitions/rename_definition.py deleted file mode 100644 index 465b3787..00000000 --- a/db/definitions/rename_definition.py +++ /dev/null @@ -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() diff --git a/db/definitions/update_field.py b/db/definitions/update_field.py deleted file mode 100644 index 3cc64c8e..00000000 --- a/db/definitions/update_field.py +++ /dev/null @@ -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() diff --git a/db/dialogue_lines.py b/db/dialogue_lines.py index 61cd361a..6b4faefa 100644 --- a/db/dialogue_lines.py +++ b/db/dialogue_lines.py @@ -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 diff --git a/db/game_dir.py b/db/game_dir.py index 921a1bc3..a9c3e4c8 100644 --- a/db/game_dir.py +++ b/db/game_dir.py @@ -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/// — repli sur l'ancien diff --git a/db/games/create_game.py b/db/games/create_game.py index 1b6b7425..3fadd751 100644 --- a/db/games/create_game.py +++ b/db/games/create_game.py @@ -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). diff --git a/db/games/delete_game.py b/db/games/delete_game.py index afa8bf4c..5f4ecade 100644 --- a/db/games/delete_game.py +++ b/db/games/delete_game.py @@ -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 diff --git a/db/games/game_meta.py b/db/games/game_meta.py index 55bade7d..d2f4ff1d 100644 --- a/db/games/game_meta.py +++ b/db/games/game_meta.py @@ -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). diff --git a/db/games/game_type_catalog.py b/db/games/game_type_catalog.py index 89f4b7ba..f28bcdee 100644 --- a/db/games/game_type_catalog.py +++ b/db/games/game_type_catalog.py @@ -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"]) diff --git a/db/games/get_game_type.py b/db/games/get_game_type.py index b214707c..e4f24d78 100644 --- a/db/games/get_game_type.py +++ b/db/games/get_game_type.py @@ -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é — diff --git a/db/games/get_scorm_version.py b/db/games/get_scorm_version.py index fbe0b086..8fa0f607 100644 --- a/db/games/get_scorm_version.py +++ b/db/games/get_scorm_version.py @@ -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"]) diff --git a/db/games/get_success_threshold.py b/db/games/get_success_threshold.py index 8f5b5cbb..f56eccb6 100644 --- a/db/games/get_success_threshold.py +++ b/db/games/get_success_threshold.py @@ -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 diff --git a/db/games/get_xapi_settings.py b/db/games/get_xapi_settings.py index 7e7cb1bf..517a66ef 100644 --- a/db/games/get_xapi_settings.py +++ b/db/games/get_xapi_settings.py @@ -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) diff --git a/db/games/list_games.py b/db/games/list_games.py index 06f465ef..09684a45 100644 --- a/db/games/list_games.py +++ b/db/games/list_games.py @@ -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// (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() diff --git a/db/games/move_game.py b/db/games/move_game.py index 2fd6db05..e1ab7a88 100644 --- a/db/games/move_game.py +++ b/db/games/move_game.py @@ -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, diff --git a/db/games/project_slug.py b/db/games/project_slug.py index be9a8043..6334dd0a 100644 --- a/db/games/project_slug.py +++ b/db/games/project_slug.py @@ -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 diff --git a/db/games/set_scorm_version.py b/db/games/set_scorm_version.py index 21279a6d..41114be7 100644 --- a/db/games/set_scorm_version.py +++ b/db/games/set_scorm_version.py @@ -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)) diff --git a/db/games/set_success_threshold.py b/db/games/set_success_threshold.py index cb327628..cd3fd6c5 100644 --- a/db/games/set_success_threshold.py +++ b/db/games/set_success_threshold.py @@ -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) diff --git a/db/games/set_xapi_settings.py b/db/games/set_xapi_settings.py index 0c63315b..2f1751b9 100644 --- a/db/games/set_xapi_settings.py +++ b/db/games/set_xapi_settings.py @@ -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() diff --git a/db/games/update_game_name.py b/db/games/update_game_name.py index 404d5398..00f0c783 100644 --- a/db/games/update_game_name.py +++ b/db/games/update_game_name.py @@ -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() diff --git a/db/global_vars/coerce_structured_value.py b/db/global_vars/coerce_structured_value.py index 9ab1492c..f567f1a8 100644 --- a/db/global_vars/coerce_structured_value.py +++ b/db/global_vars/coerce_structured_value.py @@ -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/ diff --git a/db/global_vars/create_global_variable.py b/db/global_vars/create_global_variable.py index cbce72ca..f88d9b1a 100644 --- a/db/global_vars/create_global_variable.py +++ b/db/global_vars/create_global_variable.py @@ -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 diff --git a/db/global_vars/delete_global_variable.py b/db/global_vars/delete_global_variable.py index 5bd95b45..90cad58d 100644 --- a/db/global_vars/delete_global_variable.py +++ b/db/global_vars/delete_global_variable.py @@ -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).""" diff --git a/db/global_vars/delete_global_variable_by_id.py b/db/global_vars/delete_global_variable_by_id.py index fb9bd827..fa23a08c 100644 --- a/db/global_vars/delete_global_variable_by_id.py +++ b/db/global_vars/delete_global_variable_by_id.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é diff --git a/db/global_vars/ensure_global_vars_schema.py b/db/global_vars/ensure_global_vars_schema.py index 43533774..c3fb25ca 100644 --- a/db/global_vars/ensure_global_vars_schema.py +++ b/db/global_vars/ensure_global_vars_schema.py @@ -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 diff --git a/db/global_vars/get_global_variable.py b/db/global_vars/get_global_variable.py index f2db00a7..9f39f6b8 100644 --- a/db/global_vars/get_global_variable.py +++ b/db/global_vars/get_global_variable.py @@ -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 diff --git a/db/global_vars/list_global_variables.py b/db/global_vars/list_global_variables.py index 7aa7e635..048b117c 100644 --- a/db/global_vars/list_global_variables.py +++ b/db/global_vars/list_global_variables.py @@ -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 diff --git a/db/global_vars/list_global_variables_for_player.py b/db/global_vars/list_global_variables_for_player.py index 53508b4b..d9ec92e8 100644 --- a/db/global_vars/list_global_variables_for_player.py +++ b/db/global_vars/list_global_variables_for_player.py @@ -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) diff --git a/db/global_vars/resolve_player_key.py b/db/global_vars/resolve_player_key.py index db381bb1..19427ab2 100644 --- a/db/global_vars/resolve_player_key.py +++ b/db/global_vars/resolve_player_key.py @@ -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 diff --git a/db/global_vars/update_global_variable.py b/db/global_vars/update_global_variable.py index 93e927c0..a58d4a79 100644 --- a/db/global_vars/update_global_variable.py +++ b/db/global_vars/update_global_variable.py @@ -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 diff --git a/db/global_vars/update_global_variable_value.py b/db/global_vars/update_global_variable_value.py index 4360c08a..3aac733f 100644 --- a/db/global_vars/update_global_variable_value.py +++ b/db/global_vars/update_global_variable_value.py @@ -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: diff --git a/db/json_for_script.py b/db/json_for_script.py new file mode 100644 index 00000000..efa661dc --- /dev/null +++ b/db/json_for_script.py @@ -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 "" (nom d'écran/objet/variable, texte de + dialogue... n'importe quel champ libre du créateur) refermerait + prématurément la balise " refermerait - # prématurément la balise