# 2 — Maintenabilité 16 constats sur la structure du code, l'outillage et la chaîne de livraison. Références de lignes sur `immortal/main` @ `bb0bc1c`. Le renommage `supporting_scrits` → `supporting_scripts` relevé lors de la première passe a été effectué par l'équipe — voir [`00-provenance.md`](00-provenance.md). | ID | Constat | Sévérité | |---|---|---| | [MNT-01](#mnt-01--) | `.gitignore` ignore `*.html` : tout nouveau template est invisible pour git | 🟠 Élevé | | [MNT-02](#mnt-02--) | `requirements.txt` encodé en UTF-16 | 🟠 Élevé | | [MNT-03](#mnt-03--) | Aucun test, et le job CI « Tests » est un leurre | 🟠 Élevé | | [MNT-04](#mnt-04--) | Le job CI « Security Scan » pointe vers un fichier inexistant | 🟠 Élevé | | [MNT-05](#mnt-05--) | Trois dépendances parasites, dont un doublon de `python-dotenv` | 🟡 Moyen | | [MNT-06](#mnt-06--) | Aucune configuration Ruff alors que la CI exige `ruff format --check` | 🟡 Moyen | | [MNT-07](#mnt-07--) | `backup.py` cible SQLite alors que l'application impose PostgreSQL | 🟡 Moyen | | [MNT-08](#mnt-08--) | `users.py` : 1 245 lignes, six responsabilités distinctes | 🟡 Moyen | | [MNT-09](#mnt-09--) | Code mort : imports et constantes jamais utilisés | 🟡 Moyen | | [MNT-10](#mnt-10--) | Requêtes N+1 systématiques dans six modules de routes | 🟡 Moyen | | [MNT-11](#mnt-11--) | `delete_team` laisse des références orphelines | 🟡 Moyen | | [MNT-12](#mnt-12--) | Duplication du parsing date/heure dans quatre modules | 🔵 Faible | | [MNT-13](#mnt-13--) | `datetime.utcnow()` déprécié — 12 occurrences | 🔵 Faible | | [MNT-14](#mnt-14--) | Aucune pagination sur les listes | 🔵 Faible | | [MNT-15](#mnt-15--) | README en décalage avec le code | 🔵 Faible | | [MNT-16](#mnt-16--) | Fichier d'état d'exécution `discord_pending.json` versionné | 🔵 Faible | --- ## MNT-01 · 🟠 **`.gitignore` ignore `*.html` : tout nouveau template est invisible pour git** `.gitignore:23-24` ``` docs/ *.html ``` Ces deux règles n'ont pas de portée restreinte. Vérification : ``` $ git check-ignore -v --no-index app/templates/pages/newpage.html .gitignore:24:*.html app/templates/pages/newpage.html $ git check-ignore -v --no-index docs/newdoc.html .gitignore:23:docs/ docs/newdoc.html ``` Les 40 templates existants restent suivis (git conserve ce qui est déjà indexé), et les fichiers de `docs/` ont manifestement été ajoutés en forçant. Mais **tout nouveau fichier `.html`, où qu'il soit dans l'arborescence, est silencieusement ignoré**. **Impact.** C'est le constat le plus insidieux du rapport. Une personne qui ajoute une page à l'application la verra fonctionner en local, fera son `git add .`, `git commit`, `git push` — sans aucun avertissement — et l'application sera cassée en production avec une `TemplateNotFound`. Le diagnostic est difficile : le fichier existe bien sur le poste de développement, la revue de PR ne montre rien d'anormal, et `git status` reste propre. Le même piège s'applique à toute documentation ajoutée sous `docs/` (d'où le choix de placer le présent audit dans `audit/` et non dans `docs/`). **Correction.** Remplacer les deux lignes par des règles ciblées sur ce qui était réellement visé — vraisemblablement les rapports de couverture générés et les documents d'architecture exportés : ```gitignore # rapports générés htmlcov/ coverage_html_report/ ``` Puis vérifier ce qui manque déjà : ```bash git status --ignored --short | grep '\.html$' git add -f docs/ # si l'on souhaite versionner la documentation existante ``` Passer en revue le reste du fichier au passage : `*.db` y figure deux fois (`:5` et `:33`), `instance/` et `.instance/` cohabitent. --- ## MNT-02 · 🟠 **`requirements.txt` encodé en UTF-16** Les premiers octets du fichier sont `FF FE 61 00` : nomenclature UTF-16 LE, puis `a` codé sur deux octets. Le fichier fait 1 708 octets pour 47 lignes — soit environ le double de la taille attendue. C'est le résultat classique d'un `pip freeze > requirements.txt` exécuté depuis PowerShell, dont la redirection produit de l'UTF-16 par défaut sur Windows PowerShell 5.1. **Impact.** - `pip install -r requirements.txt` fonctionne sur les versions récentes de pip (qui détectent la nomenclature), mais échoue ou produit des noms de paquets corrompus sur des versions plus anciennes et dans certaines images de conteneurs. - **`pip-audit` ne sait pas parser ce format** : le job CI « Security Audit » (`ci.yml:30-31`) est donc au mieux inopérant. Sa ligne de commande le masque d'ailleurs : `pip-audit --require-hashes --no-deps || pip-audit` — le `||` avale l'échec du premier appel, et le fichier ne contient aucun hachage, donc `--require-hashes` ne pouvait de toute façon pas réussir. - Les diffs git sont illisibles, ce qui rend toute revue de changement de dépendance impossible. **Correction.** ```bash python -c "open('requirements.txt','w',encoding='utf-8',newline='\n').write(open('requirements.txt',encoding='utf-16').read())" ``` Puis ajouter un `.gitattributes` pour éviter la récidive : ```gitattributes * text=auto eol=lf requirements.txt text eol=lf working-tree-encoding=UTF-8 ``` Et sous PowerShell, préférer `pip freeze | Out-File -Encoding utf8 requirements.txt`. --- ## MNT-03 · 🟠 **Aucun test, et le job CI « Tests » est un leurre** `.github/workflows/ci.yml:74-94` ```yaml test: name: Tests needs: [security-audit, lint] steps: … - name: Run tests run: | echo "No tests configured yet. Add tests to the project." # python -m pytest tests/ --cov=. --cov-report=xml continue-on-error: true ``` Il n'existe aucun répertoire `tests/`, aucun fichier `test_*.py`, et ni `pytest` ni `pytest-cov` ne figurent dans `requirements.txt`. **Impact.** Le job affiche un statut vert dans l'interface GitHub. Pour quiconque regarde la liste des checks d'une PR, l'application est « testée ». Le `continue-on-error: true` garantit en plus que ce job ne pourra jamais bloquer une fusion, même une fois de vrais tests ajoutés — il faudra penser à retirer ce drapeau. L'absence de tests est particulièrement coûteuse ici, parce que le code contient exactement le type de logique qui en réclame : une matrice de permissions à cinq rôles, avec des méthodes `can_manage_this_tryout` / `can_manage_this_org_team` dont le comportement diffère par sous-classe. C'est vérifiable en quelques dizaines de lignes de tests, et invérifiable à la main. **Correction.** Commencer par la valeur maximale — la matrice d'autorisation : ```python # tests/test_permissions.py import pytest @pytest.mark.parametrize('role,expected', [ ('admin', True), ('manager', True), ('coach', False), ('player', False), ('scout', False), ]) def test_can_manage_teams(role, expected, user_factory): assert user_factory(role).can_manage_teams() is expected ``` Puis, par ordre de rendement : 1. les méthodes `can_*` de chaque sous-classe de `User` (test unitaire pur, sans base) ; 2. les schémas Marshmallow de `validators.py` (idem) ; 3. des tests d'intégration sur les routes sensibles avec le client de test Flask : chaque rôle contre chaque route, en vérifiant les 403/redirections — c'est ce qui aurait détecté SEC-15 et SEC-17. Ajouter `pytest`, `pytest-cov` et `factory-boy` à un `requirements-dev.txt`, puis activer réellement le job (`continue-on-error` retiré, seuil de couverture progressif). --- ## MNT-04 · 🟠 **Le job CI « Security Scan » pointe vers un fichier inexistant** `.github/workflows/ci.yml:72` ```yaml - name: Run security scan run: python security_scan.py --skip-http ``` Le script se trouve en réalité à `app/supporting_scripts/security_scan.py`. Il n'y a pas de `security_scan.py` à la racine du dépôt. > Le renommage du dossier (commit `0dd4ecd`, `supporting_scrits` → `supporting_scripts`) **n'a pas corrigé la CI** : le chemin invoqué était déjà erroné avant, il l'est toujours après. Le job échoue donc à chaque exécution avec `can't open file 'security_scan.py': [Errno 2] No such file or directory`. Contrairement au job `test`, celui-ci n'a pas de `continue-on-error` : il apparaît **en rouge en permanence**. **Impact.** Une CI qui est toujours rouge cesse d'être un signal. L'équipe s'habitue à fusionner malgré l'échec, et le jour où un vrai problème est détecté, il passe inaperçu. C'est un coût de maintenance négatif : le job consomme des minutes CI et détruit la confiance dans le tableau de bord. Deux problèmes secondaires dans le même job : - l'exécution du script importe la configuration de l'application, qui exige `DATABASE_URL` (`app.py:58-62`) ; or seul `SECRET_KEY` est fourni (`ci.yml:70`) — le script échouerait aussi pour cette raison ; - le repli `secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890'` fournit une clé de 34 caractères, ce qui passe le contrôle de longueur du script (`security_scan.py:42`) mais valide un scénario qui n'est pas celui de la production. **Correction.** ```yaml - name: Run security scan env: SECRET_KEY: ${{ secrets.CI_SECRET_KEY }} DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ci FLASK_DEBUG: 'false' run: python -m app.supporting_scripts.security_scan --skip-http ``` Et faire échouer le job si le script renvoie un code non nul, ce qu'il fait déjà via son `exit(main())`. Vérifier aussi que le job `lint` passe (voir MNT-06) : dans l'état actuel, `test` dépend de `lint` via `needs`, donc un `lint` rouge empêche `test` de s'exécuter. --- ## MNT-05 · 🟡 **Trois dépendances parasites, dont un doublon de `python-dotenv`** `requirements.txt` est un `pip freeze` brut de 47 entrées, mêlant dépendances directes et transitives. Trois entrées posent problème : | Ligne | Paquet | Problème | |---|---|---| | `:15` | `dotenv==0.9.9` | Doublon confusant de `python-dotenv==1.2.2` (`:36`), déjà présent. Le paquet `dotenv` sur PyPI est un projet distinct et quasi vide ; c'est le nom vers lequel se trompent régulièrement les installations. Le code importe `from dotenv import load_dotenv`, qui est fourni par `python-dotenv` — `dotenv` est donc inutile. | | `:28` | `login==0.0.6` | Paquet PyPI sans rapport avec `Flask-Login` (`:19`, le vrai utilisé). Aucun `import login` dans le code. Installé par confusion de nom. | | `:13` | `discord==2.3.2` | Méta-paquet qui ne fait que réinstaller `discord.py`, déjà épinglé ligne `:14` en version 2.7.1. Les deux versions divergent. | **Impact.** Surface d'approvisionnement élargie sans contrepartie : trois paquets supplémentaires exécutent leur `setup.py` à l'installation et sont dans le chemin d'import. Les paquets aux noms proches de bibliothèques populaires (`dotenv`, `login`) sont précisément la cible privilégiée des attaques par confusion de dépendances. Ils sont ici bénins, mais leur présence indique que la liste n'est pas relue. **Un quatrième point mérite une vérification urgente.** Le pilote PostgreSQL est passé de `psycopg2==2.9.12` à **`psycopg[binary]`, sans version épinglée** — seule entrée non épinglée d'un fichier qui l'est partout ailleurs. Deux conséquences : - La reproductibilité est rompue : deux installations à quelques semaines d'intervalle n'obtiendront pas la même version du pilote. - **`psycopg[binary]` est psycopg 3, pas psycopg2.** Or SQLAlchemy 2.0 résout le préfixe `postgresql://` vers le dialecte **psycopg2** par défaut. Si `DATABASE_URL` commence par `postgresql://` — ce qu'indiquait le fichier d'exemple avant son nettoyage — la création du moteur lèvera `ModuleNotFoundError: No module named 'psycopg2'` au démarrage. Le fonctionnement actuel suppose donc que la variable de production utilise la forme explicite `postgresql+psycopg://…`. **À vérifier** : si l'application tourne, c'est le cas ; sinon, c'est la cause du dysfonctionnement. Dans les deux situations, la dépendance implicite entre le format de l'URL et le pilote installé doit être documentée, ou levée en normalisant l'URI au démarrage : ```python url = os.getenv('DATABASE_URL', '') if url.startswith('postgresql://'): url = url.replace('postgresql://', 'postgresql+psycopg://', 1) app.config['SQLALCHEMY_DATABASE_URI'] = url ``` **Correction.** 1. Retirer `dotenv`, `login` et `discord` — vérifier au préalable qu'aucun `import` ne les référence (aucun n'apparaît dans le code). 2. Séparer les dépendances directes des transitives. Le standard actuel de l'écosystème est un `pyproject.toml` avec `[project] dependencies`, la résolution étant figée dans un fichier de verrouillage (`uv lock`, `pip-tools`) : ```toml [project] dependencies = [ "Flask~=3.1", "Flask-SQLAlchemy~=3.1", "Flask-Login~=0.6", "Flask-WTF~=1.3", "Flask-Limiter~=4.1", "flask-cors~=6.0", "SQLAlchemy~=2.0", "psycopg[binary]~=3.2", "marshmallow~=4.3", "python-dotenv~=1.2", "discord.py~=2.7", "APScheduler~=3.11", "waitress~=3.0", "requests~=2.34", ] ``` En épinglant `psycopg`, retenir une contrainte de version — l'absence d'épinglage est le point le plus risqué de la liste actuelle. 3. Fournir un `requirements-dev.txt` (`pytest`, `pytest-cov`, `ruff`, `pip-audit`). 4. Une fois MNT-02 corrigé, générer des hachages (`pip-compile --generate-hashes`) pour que `pip-audit --require-hashes` de `ci.yml:31` ait un sens. --- ## MNT-06 · 🟡 **Aucune configuration Ruff alors que la CI exige `ruff format --check`** `.github/workflows/ci.yml:47-51` ```yaml - name: Run ruff linter run: ruff check . --output-format=github - name: Run ruff formatter check run: ruff format --check . ``` Le dépôt ne contient **ni `pyproject.toml`, ni `ruff.toml`, ni `.ruff.toml`, ni `setup.cfg`**. Ruff s'exécute donc avec ses réglages par défaut : longueur de ligne 88, jeu de règles `E4,E7,E9,F`. **Impact.** - `ruff format --check .` va signaler la quasi-totalité des fichiers. Le style du projet — alignement des arguments sur plusieurs lignes, virgules finales, guillemets simples — ne correspond pas à la sortie du formateur Ruff, qui normalise vers des guillemets doubles. C'est un job durablement rouge, avec le même effet de désensibilisation que MNT-04. - `ruff check` avec les règles par défaut inclut `F401` (import inutilisé) : il signalera les imports morts identifiés en MNT-09, ce qui est souhaitable — mais aussi les ré-exports volontaires de `app/models/__init__.py`, où seule la ligne `:28` porte un `# noqa: F401`. Les 20 autres imports du fichier n'en ont pas et seront signalés à tort. - La limite de 88 caractères est enfreinte à de nombreux endroits (`users.py:43`, `matches.py:183`, `team_matches.py:183`…). **Correction.** Ajouter un `pyproject.toml` qui reflète le style réel du projet, puis appliquer le formatage en un commit isolé (à ajouter à `.git-blame-ignore-revs` pour ne pas polluer `git blame`) : ```toml [tool.ruff] line-length = 110 target-version = "py312" exclude = ["app/supporting_scrits/seed.py"] [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "S", "N", "UP"] ignore = ["E501"] [tool.ruff.lint.per-file-ignores] "app/models/__init__.py" = ["F401"] [tool.ruff.format] quote-style = "single" ``` `S` active les règles `flake8-bandit` — utile pour ce projet : elles auraient signalé plusieurs constats du volet sécurité. `I` trie les imports, `UP` détecte les constructions obsolètes comme `datetime.utcnow()` (MNT-13). --- ## MNT-07 · 🟡 **`backup.py` cible SQLite alors que l'application impose PostgreSQL** `app/supporting_scrits/backup.py:17,23` ```python import sqlite3 DATABASE_PATH = os.getenv('DATABASE_PATH', os.path.join(os.getcwd(), 'instance', 'team_tryouts.db')) ``` Le script sauvegarde un fichier SQLite via `sqlite3.Connection.backup()` (`:47-51`) et vérifie son intégrité par `PRAGMA integrity_check` (`:124`). Or `app/app.py:58-62` **refuse de démarrer** si `DATABASE_URL` n'est pas défini, avec le message *« must be set to a PostgreSQL connection string »*, et `.env.exemple:21` pointe vers une instance PostgreSQL hébergée sur Render. **Impact.** Le script ne trouve jamais de fichier à sauvegarder. Il ne plante même pas : `backup_database()` (`:38-40`) détecte l'absence du fichier, affiche `[WARNING] Database not found … Skipping database backup.` et renvoie `None`. `main()` poursuit, sauvegarde les documents, et **affiche `=== Backup completed successfully ===`**. C'est le pire cas possible pour une procédure de sauvegarde : elle rapporte un succès alors qu'aucune donnée n'a été sauvegardée. Si le script est planifié (le docstring évoque le Planificateur de tâches Windows), l'organisation croit disposer de sauvegardes qui n'existent pas — et ne le découvrira qu'au moment de restaurer. Le script conserve toutefois une valeur réelle : `backup_documents()` (`:59-80`) archive correctement le dossier `documents/`, qui contient les contrats signés et n'est pas dans la base. **Correction.** 1. Remplacer la sauvegarde de base par un appel à `pg_dump`, en dérivant les paramètres de `DATABASE_URL` : ```python subprocess.run(['pg_dump', '--no-owner', '--format=custom', '--file', backup_path, os.environ['DATABASE_URL']], check=True) ``` `check=True` est essentiel : c'est ce qui manque aujourd'hui, conceptuellement, au script. 2. Faire renvoyer un code de sortie non nul à `main()` quand la sauvegarde de base a échoué ou été ignorée — aujourd'hui `success` n'est mis à `False` que si la vérification d'intégrité échoue (`:164-166`), jamais si la sauvegarde n'a pas eu lieu. 3. Vérifier que Render, qui héberge la base, fournit déjà des sauvegardes automatiques — auquel cas ce script n'a besoin de couvrir que `documents/`, et doit le dire explicitement. --- ## MNT-08 · 🟡 **`users.py` : 1 245 lignes, six responsabilités distinctes** Volumétrie des modules de routes : | Module | Lignes | |---|---| | `users.py` | **1 265** | | `matches.py` | 583 | | `auth.py` | 473 | | `teams.py` | 455 | | `tryouts.py` | ~430 | | `team_matches.py` | 309 | | `evaluations.py` | 233 | | `main.py` | 139 | `users.py` regroupe six domaines fonctionnels sans lien entre eux, matérialisés par les commentaires de section du fichier lui-même : 1. gestion des comptes (`:76-241`) — CRUD administrateur ; 2. profil personnel (`:244-320`) ; 3. disponibilités (`:323-462`) — API JSON ; 4. contrats (`:465-650`) — téléversement et téléchargement de fichiers ; 5. sessions 1:1 (`:653-909`) — dont l'intégration Discord ; 6. notes (`:912-1265`) — personnelles et d'équipe, avec quatre routes de création presque identiques. `auth.py` a par ailleurs gagné 177 lignes avec le flux OAuth2 Discord (`:326-456`) et suit la même trajectoire : authentification par mot de passe, CAPTCHA et fédération d'identité désormais dans un seul fichier. **Impact.** Un fichier de cette taille concentre les conflits de fusion, ralentit la navigation, et rend la revue de code superficielle. Le symptôme visible ici : les trois schémas de validation importés en tête ont cessé d'être utilisés (SEC-06) sans que personne ne le remarque, parce que la déclaration et l'usage sont séparés par plusieurs centaines de lignes. Le préfixe d'URL trahit le problème : `url_prefix='/users'` produit des routes comme `/users/one-on-one` et `/users/contracts//download`, qui ne concernent pas la gestion des utilisateurs. **Correction.** Scinder en blueprints alignés sur les domaines, avec leurs propres préfixes : ``` app/routes/ users.py → /users (comptes + profil) availability.py → /availability (disponibilités joueurs + coachs) contracts.py → /contracts one_on_one.py → /one-on-one notes.py → /notes ``` Les redirections des anciennes URL sont à prévoir, ou à assumer comme rupture si l'application n'a pas d'usagers externes. Les quatre routes de création de note (`manage_personal_notes` `:1081`, `add_personal_note` `:1116`, `add_note_from_tryout` `:1157`, `add_note_from_match` `:1203`) partagent 80 % de leur corps et gagneraient à être unifiées derrière une seule route acceptant un contexte optionnel. --- ## MNT-09 · 🟡 **Code mort : imports et constantes jamais utilisés** Vérifié par comptage d'occurrences dans `app/routes/users.py` — une seule occurrence signifie que le nom n'apparaît que sur sa ligne de déclaration : | Nom | Ligne | Occurrences | |---|---|---| | `CreateUserSchema` | `:24` | 1 | | `EditUserSchema` | `:24` | 1 | | `EditProfileSchema` | `:24` | 1 | | `csrf` | `:10` | 1 | | `ALLOWED_CONTRACT_EXTENSIONS` | `:29` | 1 | | `ALLOWED_SIGNED_EXTENSIONS` | `:30` | 1 | | `date_type` | `:21` | 1 | Ailleurs : - `app/app.py:19-30` — filtre `nl2br` enregistré, jamais employé dans aucun template (cf. SEC-09) ; - `app/logging_config.py:148-154` — `get_auth_logger()` jamais appelé (cf. SEC-19) ; le fichier `auth.log` et son handler sont configurés pour rien ; - `app/routes/users.py:636` — `DISCORD_WEBHOOK_URL` lu au moment de l'import du module, ce qui empêche toute modification par test ou rechargement de configuration. **Impact.** Deux des entrées de cette liste ne sont pas de simples résidus : les trois schémas et les deux constantes d'extension **décrivent un comportement de sécurité que le lecteur croit implémenté**. Un relecteur voyant `ALLOWED_SIGNED_EXTENSIONS = {'pdf'}` en tête de fichier conclut raisonnablement que les téléversements sont filtrés. Ils ne le sont pas (SEC-11). Le code mort ment ici sur les propriétés du système. **Correction.** Deux traitements distincts : - **Rebrancher** ce qui devait l'être : les trois schémas (SEC-06), les deux constantes d'extension (SEC-11), `get_auth_logger` (SEC-19). - **Supprimer** le reste : `csrf`, `date_type`, et le filtre `nl2br` s'il n'est pas destiné à servir. `ruff check` avec la règle `F401` (active par défaut) détecte les imports inutilisés — le job `lint` de la CI les aurait signalés s'il s'exécutait (MNT-06). Les constantes de module ne sont pas couvertes par `F401` ; `vulture` peut compléter. --- ## MNT-10 · 🟡 **Requêtes N+1 systématiques dans six modules de routes** Le motif « requête dans une boucle » est présent dans `evaluations.py`, `main.py`, `matches.py`, `teams.py`, `tryouts.py` et `users.py`. Exemples représentatifs : `routes/tryouts.py:163` — une requête par joueur inscrit : ```python registered_players = [User.query.get(r.player_id) for r in registrations if r.player_id] ``` `routes/tryouts.py:168-172` — puis une requête d'évaluation par joueur, dans la même vue : ```python for p in registered_players: existing = Evaluation.query.filter_by(tryout_id=…, player_id=p.id, evaluator_id=…).first() ``` `routes/users.py:320-335` (`get_disponibilities`) — la liste complète des joueurs actifs, puis leurs disponibilités une par une via la relation. `routes/matches.py:536-541` (`get_players_available_at_time`) — boucle sur tous les joueurs actifs avec une requête `PlayerDisponibility` chacun. `routes/matches.py:226` — cas notable, la requête est exécutée **deux fois par joueur** : ```python all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)] ``` **Impact.** À l'échelle actuelle d'une association étudiante (quelques dizaines de joueurs), l'effet est imperceptible. Le point d'attention est la trajectoire : `view_tryout` (`tryouts.py:134-252`) exécute déjà, pour un tryout de 30 inscrits avec 4 matchs, de l'ordre de 100 requêtes par affichage de page. Sur une base PostgreSQL distante (Render), chaque requête paie une latence réseau — c'est là que le coût devient visible, bien avant qu'il ne le soit en charge CPU. **Correction.** Charger en une requête plutôt qu'en boucle : ```python # au lieu de [User.query.get(r.player_id) for r in registrations] player_ids = [r.player_id for r in registrations] players = {u.id: u for u in User.query.filter(User.id.in_(player_ids))} registered_players = [players[pid] for pid in player_ids if pid in players] ``` Pour les relations, utiliser le chargement anticipé de SQLAlchemy : ```python from sqlalchemy.orm import selectinload registrations = TryoutRegistration.query.options( selectinload(TryoutRegistration.player) ).filter_by(tryout_id=tryout_id).all() ``` Plusieurs relations sont déclarées `lazy='dynamic'` (`models/user_model/user.py:42-52`), ce qui interdit le chargement anticipé. Ce mode n'a d'intérêt que si l'on filtre systématiquement la relation ; sinon `lazy='select'` (défaut) ou `lazy='selectin'` est préférable. Activer `SQLALCHEMY_RECORD_QUERIES` en développement pour mesurer avant d'optimiser. --- ## MNT-11 · 🟡 **`delete_team` laisse des références orphelines** `app/routes/teams.py:202-224` ```python tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all() for t in tryouts: t.target_org_team_id = None db.session.commit() TeamPlayer.query.filter_by(org_team_id=team_id).delete() db.session.commit() db.session.delete(team) db.session.commit() ``` Seuls `Tryout.target_org_team_id` et `TeamPlayer` sont traités. Trois modèles référencent pourtant `org_team_id` et ne sont pas nettoyés : `TeamNote`, `TeamMatch` et `OneOnOneRequest`. `OrgTeam.coaches` / `OrgTeam.managers` (tables d'association) ne sont pas vidés non plus. **Impact.** Selon les contraintes effectivement créées par `db.create_all()`, la suppression lève une `IntegrityError` (violation de clé étrangère) → HTTP 500, l'équipe n'est pas supprimée, et l'utilisateur ne comprend pas pourquoi. Le gestionnaire 500 fait bien le `rollback` (`app.py:316`), donc pas de corruption — mais la fonctionnalité est inutilisable dès qu'une équipe a des notes ou des matchs, c'est-à-dire dans tous les cas réels. Second problème, structurel : la fonction enchaîne **trois `commit()` successifs**. Si le troisième échoue, les deux premiers sont déjà persistés — les tryouts ont perdu leur équipe cible et les joueurs ont été retirés, alors que l'équipe existe toujours. L'opération n'est pas atomique. À comparer avec `delete_user` (`users.py:142-185`), qui traite 15 modèles liés et ne commite qu'une fois à la fin — c'est le bon modèle à suivre. **Correction.** À court terme, aligner sur `delete_user` : traiter toutes les tables liées, un seul `commit()` final. À moyen terme, le problème de fond est que ces cascades sont écrites à la main dans les vues. Les déclarer sur les relations est plus sûr, parce que la base garantit alors la cohérence quel que soit le chemin de suppression : ```python # models/org_team/org_team.py notes = db.relationship('TeamNote', backref='org_team', cascade='all, delete-orphan', passive_deletes=True) ``` avec `ondelete='CASCADE'` sur la `ForeignKey` correspondante. Cela suppose des migrations (cf. STD-01), les contraintes existantes ayant été créées sans. --- ## MNT-12 · 🔵 **Duplication du parsing date/heure dans quatre modules** Le même bloc apparaît, à quelques variantes près, dans `matches.py` (`:244-263`, `:368-392`), `team_matches.py` (`:135-158`, `:216-236`), `users.py` (`:366-371`, `:732-741`, `:947-951`) et `tryouts.py` (`:60-65`, `:110-115`) : ```python try: date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() start_time = datetime.strptime(start_time_str, '%H:%M').time() if end_time_str: end_time = datetime.strptime(end_time_str, '%H:%M').time() else: end_time = (datetime.combine(date_obj, start_time) + timedelta(minutes=30)).time() except (ValueError, TypeError): flash('Invalid date format.', 'danger') return … ``` La règle métier « une plage sans heure de fin dure 30 minutes » est réimplémentée cinq fois. `add_30_minutes()` existe pourtant déjà dans `users.py:309-310`, mais n'est utilisée que localement. Les traitements divergent d'ailleurs entre copies : `team_matches.py:227-236` avale silencieusement les heures invalides (`except ValueError: pass`), alors que `matches.py:261-263` affiche une erreur, et `matches.py:390-391` met `start_time` à `None`. Trois comportements différents pour la même saisie invalide. **Correction.** Extraire dans un module `app/utils/datetimes.py` : ```python DEFAULT_SLOT_MINUTES = 30 def parse_date(value: str) -> date: ... def parse_time_range(start: str, end: str | None, on: date) -> tuple[time, time]: ... ``` Ces fonctions ont l'avantage d'être testables sans base ni contexte de requête — un bon premier chantier de tests (MNT-03). Mieux encore : ces conversions relèvent de la validation d'entrée. `validators.py` définit déjà `OneOnOneRequestSchema` avec des champs date/heure (`:407-441`), mais en `fields.String` avec `validate.Regexp` plutôt qu'en `fields.Date` / `fields.Time`, qui feraient la conversion et le contrôle en une étape. --- ## MNT-13 · 🔵 **`datetime.utcnow()` déprécié — 12 occurrences** `datetime.utcnow()` est déprécié depuis Python 3.12. L'application cible Python 3.12 (`ci.yml:24`, `:42`, `:62`). Le problème n'est pas seulement l'avertissement : la méthode renvoie un `datetime` **naïf** (sans fuseau) contenant une heure UTC. Comparé à un `datetime` local, le résultat est silencieusement faux. Occurrences : `models/user_model/user.py:24`, `models/*` (valeurs par défaut de `created_at`), `routes/auth.py:114,115,153`, `routes/users.py:601,824,867`, `routes/teams.py:66`, `routes/tryouts.py:34,252`, `routes/team_matches.py:87`. Le cas le plus sensible est `auth.py:114` : ```python if user and user.locked_until and user.locked_until > datetime.utcnow(): ``` La comparaison est correcte tant que `locked_until` est écrit par le même appel naïf (`:153`) — c'est le cas. Mais toute introduction d'une valeur consciente du fuseau, par exemple via le bot Discord qui utilise `ZoneInfo('America/Toronto')` (`discord_bot.py:55`), lèvera un `TypeError: can't compare offset-naive and offset-aware datetimes`. **Correction.** ```python from datetime import datetime, timezone datetime.now(timezone.utc) ``` Et déclarer les colonnes en `db.DateTime(timezone=True)`. Le changement doit être fait d'un bloc : mélanger valeurs naïves et conscientes dans une même colonne produit des comparaisons erronées. Les données existantes étant en UTC naïf, une migration les convertit sans perte. La règle Ruff `DTZ` (`flake8-datetimez`) détecte ces appels — à ajouter au `select` de MNT-06. --- ## MNT-14 · 🔵 **Aucune pagination sur les listes** Toutes les vues de liste chargent l'intégralité de la table : | Route | Ligne | Requête | |---|---|---| | `users.list_users` | `users.py:84` | `User.query.order_by(…).all()` | | `evaluations.list_evaluations` | `evaluations.py:76-80` | trois `outerjoin` puis `.all()` | | `teams.list_teams` | `teams.py:48-50` | trois requêtes `.all()` (coachs, managers, **tous les joueurs**) | | `team_matches.list_matches` | `team_matches.py:69` | `.all()`, puis boucle sur les participants | | `matches.api_events` | `matches.py:46-136` | tous les tryouts visibles, tous leurs matchs, tous les participants | `teams.list_teams` charge `all_players` (`:50`) pour peupler des listes déroulantes du formulaire — la totalité des joueurs de l'organisation est sérialisée dans le HTML à chaque affichage de la page équipes. **Impact.** Nul aujourd'hui, à l'échelle d'une association étudiante. Le constat est consigné pour la trajectoire : combiné aux N+1 de MNT-10, `api_events` est la route qui se dégradera en premier, puisqu'elle cumule le chargement complet et les requêtes en boucle, et qu'elle est appelée à chaque ouverture du calendrier. **Correction.** Différer, mais choisir dès maintenant le motif pour ne pas avoir à le rétro-adapter. Flask-SQLAlchemy fournit `paginate()` : ```python page = request.args.get('page', 1, type=int) pagination = User.query.order_by(User.role, User.username).paginate(page=page, per_page=50) return render_template('pages/users.html', users=pagination.items, pagination=pagination) ``` Pour les listes déroulantes, préférer un point d'API filtré par saisie (autocomplétion) plutôt que l'injection de la table complète dans le HTML. --- ## MNT-15 · 🔵 **README en décalage avec le code** La section « Security Features Implemented » (`README.md:11-21`) énonce des garanties qui ne correspondent pas au code : | Affirmation | Réalité | |---|---| | *« Authorization Checks: Proper ownership validation on all sensitive operations »* | Faux pour `view_user` (SEC-15) et `add_to_team` (SEC-17) | | *« Rate Limiting: Login endpoint limited to 10 requests per minute »* | La limite existe mais s'applique à un seau global (SEC-05) | | *« Secure Session Cookies: HTTPSOnly… »* | Il s'agit de `HttpOnly` ; et `SESSION_COOKIE_SECURE` vaut `false` dans le fichier d'exemple | Le README ne mentionne par ailleurs ni la commande de démarrage exacte, ni la version de Python requise, ni la procédure d'installation (`pip install -r requirements.txt`), ni le fait qu'une base PostgreSQL est obligatoire au démarrage. Un nouvel arrivant ne peut pas lancer le projet en suivant le document. **Impact.** Une documentation de sécurité fausse est plus nuisible qu'absente : elle sert de base aux décisions de déploiement et coupe court aux vérifications. C'est vraisemblablement ce qui explique que les constats SEC-06 et SEC-15 aient survécu. Le README ne documente par ailleurs **rien de ce qui a été ajouté depuis** : ni le flux OAuth2 Discord et ses trois variables d'environnement (`DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_REDIRECT_URI`), ni `clear_db.py`, ni la chaîne de déploiement `.gitea/workflows/git-to-ptero.yaml`, ni le fait que le dépôt de référence n'est pas GitHub. **Correction.** - Réécrire la section sécurité pour décrire l'état réel, en distinguant ce qui est implémenté de ce qui est prévu. - Ajouter les prérequis (version de Python, PostgreSQL obligatoire au démarrage), l'installation, le démarrage, et le tableau des variables d'environnement. - **Indiquer explicitement quel dépôt fait autorité.** C'est ce qui a manqué ici : l'absence de cette mention a conduit à auditer un miroir en retard de 16 commits. Un `README.md` sur le miroir GitHub renvoyant vers `git.immortal.host` — ou la suppression du miroir — éviterait la récidive. --- ## MNT-16 · 🔵 **Fichier d'état d'exécution `discord_pending.json` versionné** Le fichier `discord_pending.json` est suivi par git à la racine du dépôt. Son contenu actuel : ```json {} ``` Le nom et l'usage indiquent un état d'exécution du bot Discord — vraisemblablement le suivi des demandes en attente de réaction, équivalent persistant du dictionnaire `self.pending_requests` de `discord_bot.py:52`. **Impact.** Un fichier d'état écrit par l'application et versionné pose trois problèmes : 1. **Conflits de fusion permanents.** Dès que le bot écrit dedans, le fichier apparaît modifié dans `git status`. Chaque développeur qui lance l'application localement produit une modification non intentionnelle, qu'il committera par inadvertance ou devra écarter à chaque fois. 2. **Écrasement au déploiement.** Le workflow SFTP ([SEC-21](01-securite.md#sec-21--)) pousse le contenu du dépôt sur le serveur : chaque déploiement **remet l'état du bot à `{}`**, perdant les demandes en attente. Les réactions ✅/❌ sur les messages Discord antérieurs cesseront d'être reconnues. 3. **Fuite potentielle.** Selon ce qui y est stocké (identifiants Discord, identifiants de demandes 1:1), le fichier peut contenir des données personnelles qui n'ont rien à faire dans un dépôt. **Correction.** - Retirer le fichier du suivi (`git rm --cached discord_pending.json`) et l'ajouter au `.gitignore`. - Mieux : persister cet état **en base**, où vivent déjà les `OneOnOneRequest`. Un fichier JSON local ne survit ni au déploiement, ni à la mise à l'échelle ([STD-03](03-standards-stack.md#std-03--)), et n'est pas partageable entre plusieurs instances du bot. - Vérifier au passage qu'aucune donnée personnelle n'a été committée dans ses versions antérieures (`git log -p -- discord_pending.json`).