Files
team-tryouts/audit/02-maintenabilite.md
T
GGThedandClaude Opus 5 fa0a378827 docs(audit): audit securite, maintenabilite et standards de la stack
Revue statique de l'ensemble du code Python, de la configuration CI/nginx,
du .gitignore et des dependances. 44 constats documentes avec references
fichier:ligne, impact et correctif propose.

- audit/01-securite.md         19 constats (4 critiques)
- audit/02-maintenabilite.md   15 constats
- audit/03-standards-stack.md  10 ecarts aux conventions Flask/SQLAlchemy
- audit/plan-remediation.md    ordre de traitement en 6 lots

Points critiques : secrets de production reels committes dans
app/.env.exemple, seed automatique en production avec mot de passe
password, CORS ouvert a toutes les origines avec credentials par
defaut, token du bot Discord imprime sur stdout au demarrage.

Aucune modification du code applicatif.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 13:02:54 -04:00

32 KiB

2 — Maintenabilité

15 constats sur la structure du code, l'outillage et la chaîne de livraison.

ID Constat Sévérité
MNT-01 .gitignore ignore *.html : tout nouveau template est invisible pour git 🟠 Élevé
MNT-02 requirements.txt encodé en UTF-16 🟠 Élevé
MNT-03 Aucun test, et le job CI « Tests » est un leurre 🟠 Élevé
MNT-04 Le job CI « Security Scan » pointe vers un fichier inexistant 🟠 Élevé
MNT-05 Trois dépendances parasites, dont un doublon de python-dotenv 🟡 Moyen
MNT-06 Aucune configuration Ruff alors que la CI exige ruff format --check 🟡 Moyen
MNT-07 backup.py cible SQLite alors que l'application impose PostgreSQL 🟡 Moyen
MNT-08 users.py : 1 245 lignes, six responsabilités distinctes 🟡 Moyen
MNT-09 Code mort : imports et constantes jamais utilisés 🟡 Moyen
MNT-10 Requêtes N+1 systématiques dans six modules de routes 🟡 Moyen
MNT-11 delete_team laisse des références orphelines 🟡 Moyen
MNT-12 Duplication du parsing date/heure dans quatre modules 🔵 Faible
MNT-13 datetime.utcnow() déprécié — 12 occurrences 🔵 Faible
MNT-14 Aucune pagination sur les listes 🔵 Faible
MNT-15 README en décalage avec le code, et dossier supporting_scrits mal orthographié 🔵 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 :

# rapports générés
htmlcov/
coverage_html_report/

Puis vérifier ce qui manque déjà :

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.

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 :

* 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

  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 :

# 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

      - name: Run security scan
        run: python security_scan.py --skip-http

Le script se trouve en réalité à app/supporting_scrits/security_scan.py. Il n'y a pas de security_scan.py à la racine du dépôt.

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.

      - 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_scrits.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-dotenvdotenv 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.

Par ailleurs, psycopg2==2.9.12 (:35) exige une chaîne de compilation C et les en-têtes PostgreSQL ; psycopg2-binary est le choix usuel pour un déploiement sans compilation, ou psycopg[binary] (v3) pour un projet neuf.

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) :
    [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", "psycopg2-binary~=2.9", "marshmallow~=4.3",
      "python-dotenv~=1.2", "discord.py~=2.7", "APScheduler~=3.11",
      "waitress~=3.0", "requests~=2.34",
    ]
    
  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

      - 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) :

[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

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 :
    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 245
matches.py 583
teams.py 455
tryouts.py 432
team_matches.py 309
auth.py 296
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-228) — CRUD administrateur ;
  2. profil personnel (:231-299) ;
  3. disponibilités (:302-441) — API JSON ;
  4. contrats (:444-629) — téléversement et téléchargement de fichiers ;
  5. sessions 1:1 (:632-888) — dont l'intégration Discord ;
  6. notes (:891-1245) — personnelles et d'équipe, avec quatre routes de création presque identiques.

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/<id>/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-154get_auth_logger() jamais appelé (cf. SEC-19) ; le fichier auth.log et son handler sont configurés pour rien ;
  • app/routes/users.py:636DISCORD_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 :

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 :

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 :

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 :

# 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 :

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

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 :

# 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) :

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 :

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 :

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.

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() :

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, et dossier supporting_scrits mal orthographié

README. 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.

Nommage. Le dossier app/supporting_scrits/ contient une faute (scritsscripts). Elle se propage dans tous les imports (app.py:355, ci.yml, docstrings) et dans les chemins que les développeurs tapent quotidiennement.

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. Y ajouter les prérequis, l'installation et le démarrage.
  • Renommer le dossier en app/supporting_scripts/ (git mv), en mettant à jour app/app.py:355 et .github/workflows/ci.yml:72 — ce dernier étant de toute façon à corriger (MNT-04).