# 3 — Respect des standards de la stack Écarts aux conventions établies de Flask, SQLAlchemy et du déploiement d'applications web Python. | ID | Constat | Sévérité | |---|---|---| | [STD-01](#std-01--) | Pas de migrations : `db.create_all()` au démarrage | 🟠 Élevé | | [STD-02](#std-02--) | `create_app()` déclenche des effets de bord au démarrage | 🟡 Moyen | | [STD-03](#std-03--) | Le bot Discord est démarré depuis la fabrique d'application | 🟡 Moyen | | [STD-04](#std-04--) | Trois approches de validation coexistent ; Flask-WTF inutilisé | 🟡 Moyen | | [STD-05](#std-05--) | Aucun objet de configuration ni séparation d'environnements | 🟡 Moyen | | [STD-06](#std-06--) | Ports et adresses d'écoute incohérents entre cinq fichiers | 🟡 Moyen | | [STD-07](#std-07--) | Journalisation vers des fichiers locaux plutôt que stdout | 🟡 Moyen | | [STD-08](#std-08--) | Logique métier dans les vues, sans couche de service | 🔵 Faible | | [STD-09](#std-09--) | Gestion d'erreurs API par préfixe d'URL codé en dur | 🔵 Faible | | [STD-10](#std-10--) | Pas de conteneurisation ni de procédure de déploiement reproductible | 🔵 Faible | --- ## STD-01 · 🟠 **Pas de migrations : `db.create_all()` au démarrage** `app/app.py:348-351` ```python with app.app_context(): import app.models as models from app.models import User db.create_all() ``` Ni `Flask-Migrate` ni `alembic` ne figurent dans `requirements.txt`. Un dossier `migrations/` existe désormais, mais il ne contient **pas** un outillage de migration : c'est un script unique écrit à la main, `migrations/add_tryout_coaches.py`, qui exécute du DDL en SQL brut et vérifie lui-même son idempotence en interrogeant `information_schema`. Le script est correct dans ce qu'il fait — le SQL n'interpole aucune entrée utilisateur, la création de table est conditionnelle, et la migration de données vérifie qu'elle n'a pas déjà eu lieu. Il illustre surtout le manque : chaque évolution de schéma exige de réécrire à la main ce qu'Alembic fournit. Il n'y a ni table de version, ni ordonnancement, ni migration inverse, ni moyen de savoir quelles migrations ont été appliquées sur quelle base — l'idempotence est réimplémentée au cas par cas. Il hérite en outre des effets de bord de la fabrique : `create_app()` à la ligne 15 déclenche `db.create_all()` et **démarre le bot Discord** ([STD-02](#std-02--), [STD-03](#std-03--)). Appliquer une migration ouvre une connexion Discord. `db.create_all()` **ne crée que les tables absentes**. Il ne modifie jamais une table existante : ajouter une colonne, changer un type, ajouter une contrainte d'unicité ou une clé étrangère `ON DELETE CASCADE` n'a aucun effet sur une base déjà initialisée. **Impact.** C'est le constat qui bloque le plus de corrections proposées dans ce rapport : - SEC-07 demande une contrainte d'unicité sur `User.discord_user_id` — impossible sans migration ; - MNT-11 demande des `ondelete='CASCADE'` sur les clés étrangères d'`OrgTeam` — impossible sans migration ; - MNT-13 demande de passer les colonnes en `DateTime(timezone=True)` — impossible sans migration. Au-delà, le mode de fonctionnement actuel implique que **toute évolution du schéma se fait à la main en production**, par des `ALTER TABLE` non versionnés, non revus et non rejouables. Les environnements divergent silencieusement : une base créée aujourd'hui par `create_all()` n'a pas la même structure qu'une base créée il y a six mois puis modifiée manuellement. Il n'existe aucun moyen de savoir laquelle est correcte. **Correction.** Introduire Flask-Migrate. La séquence sur une base existante : ```bash pip install Flask-Migrate flask db init flask db stamp head # déclare la base actuelle comme point de départ ``` puis, pour chaque évolution : ```bash flask db migrate -m "unicité sur discord_user_id" flask db upgrade ``` Dans `app.py`, remplacer le bloc `create_all()` par l'initialisation de l'extension : ```python from flask_migrate import Migrate migrate = Migrate() # dans extensions.py migrate.init_app(app, db) ``` et retirer `db.create_all()` de la fabrique — les migrations s'appliquent lors du déploiement, pas au démarrage du processus. **Relire systématiquement les migrations générées** : l'autogénération d'Alembic gère mal les modèles à héritage polymorphe de table unique comme `User`, et produit régulièrement des suppressions de colonnes non voulues. --- ## STD-02 · 🟡 **`create_app()` déclenche des effets de bord au démarrage** `app/app.py:33-365`. La fabrique, en plus de configurer l'application, exécute : | Ligne | Effet de bord | |---|---| | `:349` | `db.create_all()` — écriture dans le schéma | | `:353-355` | Démarrage d'un thread portant un client Discord | | `logging_config.py:66-67` | `os.makedirs('logs')` dans le répertoire de travail courant | L'insertion automatique de données de démonstration a été retirée (voir [`00-provenance.md`](00-provenance.md)) — c'était le plus grave de ces effets de bord. Les deux autres subsistent, et leurs conséquences se voient désormais très concrètement dans les deux scripts utilitaires ajoutés depuis : - `clear_db.py:54` appelle `create_app()` pour vider la base — **et démarre donc un bot Discord** au passage ; - `migrations/add_tryout_coaches.py:15` fait de même pour appliquer une migration de schéma. Aucun de ces deux scripts n'a besoin de Discord ; tous deux l'obtiennent, parce qu'il n'existe pas de moyen de construire l'application sans. Le motif de fabrique d'application (*application factory*) existe précisément pour permettre de construire plusieurs instances configurées différemment — c'est ce qui rend une application Flask testable. Ici, chaque appel à `create_app()` écrit dans la base et ouvre une connexion Discord. **Impact.** - **Les tests d'intégration sont impraticables.** Le motif standard est une fixture `app = create_app(TestConfig)` par session de test ; ici, chaque construction tenterait de se connecter à Discord et de peupler la base. C'est un facteur direct de MNT-03 (absence de tests) : l'architecture rend le premier test coûteux à écrire. - Le comportement dépend du répertoire de travail (`os.getcwd()` en `logging_config.py:66` et `users.py:541`), donc du mode de lancement — le dossier `logs/` n'apparaît pas au même endroit selon qu'on lance `python run.py` ou `python wsgi.py` depuis un autre répertoire. - Le démarrage n'est pas idempotent (cf. STD-03). **Correction.** Faire accepter une configuration à la fabrique et sortir les effets de bord : ```python def create_app(config_object=None): app = Flask(__name__) app.config.from_object(config_object or ProductionConfig) ... return app ``` - `create_all` / seed → commandes CLI (`flask db upgrade`, `flask seed-demo`), exécutées au déploiement ; - bot Discord → processus séparé (cf. STD-03) ; - chemin des logs → configurable, dérivé de `app.root_path` plutôt que de `os.getcwd()`. --- ## STD-03 · 🟡 **Le bot Discord est démarré depuis la fabrique d'application** `app/app.py:358-363` ```python try: from app.discord_bot import start_bot start_bot(flask_app=app) except Exception as e: app.logger.warning('Could not start Discord bot: %s', e) ``` `discord_bot.py` lance un thread portant une boucle asyncio, un client Discord et un `AsyncIOScheduler` avec une tâche cron quotidienne à 18h00 (`:78-84`). **Impact.** Le couplage entre le cycle de vie du serveur web et celui du bot pose trois problèmes concrets : 1. **Multiplication des instances.** Un seul processus est lancé aujourd'hui (`wsgi.py` avec Waitress multi-threads), donc un seul bot. Mais toute mise à l'échelle horizontale — plusieurs workers, deux instances derrière un équilibreur, un déploiement bleu-vert où l'ancienne et la nouvelle version tournent en parallèle — crée **autant de connexions Discord et autant de planificateurs**. Les rappels quotidiens partiraient alors en double ou en triple. Discord limite en outre le nombre de sessions simultanées par token. 2. **Défaillance silencieuse.** Le `except Exception` avale toute erreur en un simple `warning`. Si le token est invalide ou révoqué — ce qui va arriver lors de la correction de SEC-01 — l'application démarre normalement et les notifications cessent, sans alerte. 3. **Le web ne peut pas redémarrer sans couper les notifications**, et inversement. **Correction.** Séparer les processus, ce qui est le modèle standard pour une tâche de fond persistante : ``` web: python wsgi.py bot: python -m app.discord_bot ``` La communication passe alors par la base de données partagée — plutôt que par la `Queue` en mémoire actuelle (`discord_bot.py:53`), qui impose justement le partage du processus. Si la séparation est trop coûteuse à court terme, deux garde-fous minimaux : - conditionner le démarrage à une variable (`ENABLE_DISCORD_BOT`), pour pouvoir le désactiver en développement et en CI ; - poser un verrou consultatif PostgreSQL (`pg_advisory_lock`) pour garantir qu'une seule instance planifie les rappels. Journaliser l'échec en `error`, pas en `warning`. --- ## STD-04 · 🟡 **Trois approches de validation coexistent ; Flask-WTF inutilisé** Trois mécanismes se répartissent la validation des entrées, sans règle apparente : | Approche | Où | Exemple | |---|---|---| | Schémas Marshmallow | `auth.py` uniquement, plus `upload_contract` | `auth.py:206-218` | | Contrôles manuels | partout ailleurs | `evaluations.py:20-30` (`validate_score`), `tryouts.py:296` (`if new_status in [...]`) | | Aucune validation | `users.py` (create/edit user, edit profile) | SEC-06 | `Flask-WTF==1.3.0` est installé et `CSRFProtect` en est utilisé — mais **aucun `FlaskForm` n'est défini** dans le projet. La bibliothèque n'est présente que pour la protection CSRF, alors que sa fonction principale est la définition et le rendu de formulaires validés. `WTForms==3.2.2` est également installé et totalement inutilisé. **Impact.** L'absence de convention est la cause structurelle de SEC-06 : quand trois approches sont acceptables, aucune n'est obligatoire, et une route peut n'en appliquer aucune sans que cela détonne à la lecture. Les règles sont par ailleurs dupliquées — la politique de mot de passe existe dans `validators.py:22-24`, mais rien n'oblige les routes à passer par là. Les schémas Marshmallow sont bien écrits (`StripMixin` avec `unknown = EXCLUDE` pour ignorer proprement le `csrf_token`, validateurs réutilisables, messages explicites). Le problème n'est pas leur qualité mais leur application partielle. **Correction.** Choisir une approche unique et l'appliquer partout. Deux options défendables : - **Marshmallow partout** (recommandé ici, car les schémas existent déjà et couvrent presque tous les formulaires) : compléter les schémas manquants — `TeamCreateSchema`, `MatchCreateSchema`, `EvaluationSchema`, `TryoutSchema` — et faire passer chaque `request.form` par un `load()`. Un décorateur factorise le traitement des erreurs : ```python @validate_form(EditProfileSchema, on_error='pages/edit_profile.html') def edit_profile(validated): ... ``` - **Flask-WTF partout** : plus idiomatique pour une application rendue côté serveur, puisque les formulaires se rendent et se repeuplent seuls dans Jinja2 en cas d'erreur, et que la protection CSRF est intégrée par champ. Coût de conversion plus élevé, car les 40 templates écrivent leurs `` à la main. Dans les deux cas, retirer la bibliothèque non retenue des dépendances (MNT-05). --- ## STD-05 · 🟡 **Aucun objet de configuration ni séparation d'environnements** `app/app.py:54-95` affecte 12 clés de configuration une par une, en mêlant constantes et lectures d'environnement : ```python app.config['SECRET_KEY'] = os.getenv('SECRET_KEY') app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true' ... ``` D'autres réglages sont lus directement via `os.getenv()` au fil du code, hors de `app.config` : `FORCE_HTTPS` (`app.py:183`), `LOG_LEVEL` et `FLASK_DEBUG` (`logging_config.py:73,133`), `DISCORD_WEBHOOK_URL` (`users.py:657`), `DISCORD_BOT_TOKEN` (`discord_bot.py:24`) et, depuis l'ajout de l'OAuth2, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` et `DISCORD_REDIRECT_URI` (`auth.py:25-27`). `load_dotenv()` est appelé dans deux modules distincts (`app.py:16`, `discord_bot.py:23`). Les trois dernières sont lues **au moment de l'import du module**, comme `DISCORD_WEBHOOK_URL`. Conséquence pratique : elles ne peuvent être ni surchargées en test, ni rechargées sans redémarrer le processus. Et rien ne vérifie leur présence au démarrage — `discord_login` teste `DISCORD_CLIENT_ID` (`auth.py:336`) mais pas les deux autres, si bien qu'un `DISCORD_REDIRECT_URI` manquant produit un `TypeError` à la ligne 346 au lieu d'un message clair (cf. [SEC-20](01-securite.md#sec-20--)). Il n'existe **aucune notion d'environnement** : les mêmes valeurs par défaut s'appliquent en développement, en CI et en production. C'est la cause directe de SEC-02 (seed en production) et de SEC-03 (CORS permissif par défaut). **Impact.** Impossible de répondre par la lecture à des questions comme « quelle configuration s'applique en production ? » ou « qu'est-ce qui change entre local et prod ? » — il faut parcourir cinq fichiers et reconstituer les valeurs par défaut de chaque `os.getenv`. Les valeurs par défaut sont d'ailleurs incohérentes entre elles : `FLASK_DEBUG` vaut `'false'` dans `app.py:371` et `'true'` dans `run.py:20`. **Correction.** Motif standard Flask — des classes de configuration, sélectionnées par une variable d'environnement : ```python # app/config.py class BaseConfig: SQLALCHEMY_TRACK_MODIFICATIONS = False MAX_CONTENT_LENGTH = 16 * 1024 * 1024 SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' PERMANENT_SESSION_LIFETIME = 3600 WTF_CSRF_ENABLED = True class ProductionConfig(BaseConfig): SESSION_COOKIE_SECURE = True FORCE_HTTPS = True SEED_DEMO_DATA = False def __init__(self): self.SECRET_KEY = _require('SECRET_KEY') self.SQLALCHEMY_DATABASE_URI = _require('DATABASE_URL') self.CORS_ALLOWED_ORIGINS = _require('CORS_ALLOWED_ORIGINS') class DevelopmentConfig(BaseConfig): SESSION_COOKIE_SECURE = False FORCE_HTTPS = False SEED_DEMO_DATA = True ``` `_require()` lève au démarrage si la variable manque — l'application refuse de démarrer mal configurée, ce que `app.py:56-62` fait déjà pour deux clés et qu'il suffit de généraliser. C'est ce mécanisme qui aurait empêché SEC-03. Un seul `load_dotenv()`, au point d'entrée. Toute lecture de configuration passe ensuite par `current_app.config`, ce qui la rend surchargeable en test. --- ## STD-06 · 🟡 **Ports et adresses d'écoute incohérents entre cinq fichiers** | Fichier | Ligne | Écoute / cible | |---|---|---| | `run.py` | `:26` | `app.run(host='127.0.0.2', port=5000)` | | `app/app.py` | `:371` | `app.run(host='0.0.0.0', port=10000)` | | `wsgi.py` | `:24-26` | `PORT` défaut **10000**, `HOST` défaut `0.0.0.0` | | `app/.env.exemple` | `:31-32` | `HOST=127.0.0.1`, `PORT=5000` | | `app/nginx.conf` | `:122` | `proxy_pass http://127.0.0.1:5000` ✅ corrigé | Le `proxy_pass` a été corrigé (commit `10af8c0`) : il visait `0.0.0.0:5000`, ce qui n'a pas de sens comme adresse de destination. Il reste quatre combinaisons différentes pour trois points d'entrée. Points notables : - **`127.0.0.2` dans `run.py`** est presque certainement une faute de frappe pour `127.0.0.1`. L'adresse est techniquement valide (tout le bloc `127.0.0.0/8` est en boucle locale sous Linux), mais elle n'est pas joignable sous Windows sans configuration supplémentaire. - **`wsgi.py` écoute sur 10000 par défaut, nginx envoie désormais vers 5000.** Sans `PORT=5000` dans l'environnement, le proxy ne trouve pas l'application. Le docstring du même fichier (`:11`) annonce pourtant *« PORT: Port to listen on (default: 5000) »* — la documentation et le code se contredisent dans le même fichier. - **`HOST` par défaut à `0.0.0.0`** dans `wsgi.py:26`, avec un commentaire annonçant l'inverse. C'est un facteur direct de [SEC-05](01-securite.md#sec-05--). - Le commentaire de `wsgi.py:26` dit *« Bind to localhost by default (Nginx reverse proxy) »* alors que le défaut est `0.0.0.0` — l'intention est documentée, l'implémentation fait l'inverse (cf. SEC-05, dont c'est un facteur aggravant). **Impact.** La chaîne nginx → Waitress ne fonctionne pas avec les valeurs par défaut : elle exige des variables d'environnement non documentées dans le README. Un déploiement suivant la documentation aboutit à un 502. **Correction.** 1. Un seul point d'entrée de production : `wsgi.py`, avec `HOST` par défaut à `127.0.0.1` et `PORT` par défaut à `5000` pour s'aligner sur nginx et sur `.env.exemple`. 2. Supprimer le bloc `if __name__ == '__main__'` de `app/app.py`, qui fait doublon avec `run.py` et diverge de lui. 3. Corriger `run.py:26` en `127.0.0.1` et lui faire lire `HOST`/`PORT`. 4. Documenter le tableau des ports dans le README. --- ## STD-07 · 🟡 **Journalisation vers des fichiers locaux plutôt que stdout** `app/logging_config.py:66-128` configure trois `RotatingFileHandler` (10 Mo, 5 à 10 archives) écrivant dans `os.getcwd()/logs/`. Le handler console n'est ajouté **que si `FLASK_DEBUG=true`** (`:133-138`) — en production, l'application n'écrit donc **rien sur stdout**. **Impact.** Cela contredit le principe des *logs comme flux d'événements* (facteur XI des douze facteurs), qui veut que le processus écrive sans tampon sur stdout et laisse l'environnement d'exécution router, agréger et archiver. Conséquences concrètes : - `docker logs`, `journalctl`, et les collecteurs des plateformes d'hébergement (Render, entre autres, ne capture que stdout/stderr) ne voient rien. Sur un hébergement conteneurisé, **les logs sont écrits dans un système de fichiers éphémère et perdus à chaque redéploiement**. - Le chemin dépend du répertoire de travail (`os.getcwd()`), donc de la façon dont le processus a été lancé — les logs peuvent atterrir à des endroits différents selon le mode de démarrage (cf. STD-02). - Le format est textuel (`logging_config.py:81-84`), non structuré : pas d'agrégation ni de requêtage par champ. Le reste du module est de bonne facture : la séparation erreurs / auth / application est pertinente, et le `SensitiveDataFilter` (`:18-50`) est une précaution que l'on voit rarement. **Correction.** Conserver la structure, changer la destination : ```python handler = logging.StreamHandler(sys.stdout) handler.setFormatter(formatter) handler.addFilter(sensitive_filter) app.logger.addHandler(handler) ``` Garder les handlers fichier optionnels, activés par `LOG_TO_FILES=true` pour le déploiement Windows sur serveur dédié où ils gardent leur intérêt. Passer au format JSON (`python-json-logger`) si une agrégation est envisagée ; les canaux distincts deviennent alors un champ `logger` plutôt que trois fichiers. --- ## STD-08 · 🔵 **Logique métier dans les vues, sans couche de service** L'application suit le découpage blueprints / modèles / templates, mais **toute la logique métier vit dans les fonctions de vue**. Exemples : - `matches.create_match` (`matches.py:215-337`, 122 lignes) : validation, création du match, création des participants selon trois types de match, puis envoi des notifications Discord — le tout dans une seule fonction, avec des `db.session.flush()` intercalés dans les boucles ; - `users.delete_user` (`users.py:142-185`) : orchestration de la suppression de 15 modèles liés ; - `main.dashboard` (`main.py:26-139`) : cinq variantes de calcul de statistiques selon le rôle, avec des requêtes agrégées. Conséquence : **cette logique n'est atteignable que par une requête HTTP**. Tester la règle « supprimer un joueur d'un tryout le retire aussi de ses équipes et de ses matchs » impose de monter un client de test, une session authentifiée et un jeton CSRF — pour vérifier une règle qui n'a rien de HTTP. Le couplage aux notifications est le plus visible : `matches.py:325-331` et `team_matches.py:185-192` appellent `send_schedule_notification` directement dans la vue, en boucle sur les participants. Créer un match par un autre chemin (import, script, tâche planifiée) n'enverrait aucune notification. **Impact.** Faible aujourd'hui — le code reste lisible et l'application fonctionne. C'est un constat de trajectoire : c'est ce qui rend l'écriture des tests de MNT-03 plus coûteuse qu'elle ne devrait l'être, et ce qui a permis les duplications de MNT-12. **Correction.** Extraire progressivement, en commençant par ce qui est dupliqué ou testable : ```python # app/services/matches.py def create_match(tryout, form_data, author) -> Match: """Crée un match, ses participants et déclenche les notifications.""" ``` La vue se réduit alors à : valider l'entrée → appeler le service → rendre le résultat. Inutile de tout convertir d'un coup ; appliquer la règle aux nouvelles fonctionnalités et aux zones déjà remaniées suffit à inverser la tendance. --- ## STD-09 · 🔵 **Gestion d'erreurs API par préfixe d'URL codé en dur** `app/app.py:219-343`. Les sept gestionnaires d'erreur décident du format de réponse en testant le chemin : ```python if request.path.startswith('/users/disponibilities') or \ request.path.startswith('/users/coach-availability') or \ request.path.startswith('/users/api/'): return jsonify({'error': 'Bad request', 'message': str(error)}), 400 return render_template('errors/400.html', error=error), 400 ``` Le bloc est répété sept fois, avec des listes de préfixes **qui divergent entre les gestionnaires** : le 400 teste trois préfixes, les 401/403/404/429/500 n'en testent que deux (`coach-availability` a été oublié). Ces préfixes ne couvrent d'ailleurs pas toutes les routes JSON de l'application. `matches.py` expose `/matches/api/events`, `/matches/api/manageable-tryouts`, `/matches/api/available_players/…` ; `team_matches.py` expose `/team-matches/api/manageable-teams` ; `teams.py:392` et `team_matches.py:288` renvoient du JSON sur `/teams//toggle_status/` et `/team-matches//toggle-presence/`. **Aucune de ces routes n'est reconnue** : une erreur 403 sur `/matches/api/events` renvoie une page HTML à un appel `fetch()`, qui échouera au `response.json()` avec un message incompréhensible côté client. **Impact.** Comportement d'erreur incohérent selon la route, et couplage fort entre la structure d'URL et la gestion d'erreurs : renommer un blueprint casse silencieusement le format de réponse. Chaque nouvelle route JSON doit penser à s'ajouter à sept listes. **Correction.** Utiliser la négociation de contenu, qui est le mécanisme prévu par HTTP : ```python def wants_json(): return (request.accept_mimetypes.best_match(['application/json', 'text/html']) == 'application/json' or request.path.startswith('/api/')) ``` Plus propre encore : regrouper les routes JSON sous un blueprint `api_bp` avec `url_prefix='/api'` et lui attacher ses propres gestionnaires via `@api_bp.errorhandler` — Flask applique alors automatiquement le bon format selon le blueprint qui a traité la requête, sans test de chemin. Factoriser en tout état de cause les sept blocs identiques en une fonction unique. --- ## STD-10 · 🔵 **Pas de conteneurisation ni de procédure de déploiement reproductible** Le dépôt ne contient ni `Dockerfile`, ni `docker-compose.yml`, ni `Procfile`, ni manifeste de plateforme (`render.yaml`, `fly.toml`…). Le déploiement repose sur : - `app/nginx.conf`, avec des chemins Windows absolus codés en dur (`C:/nginx/certs/fullchain.pem`, `:86-87`) ; - `app/supporting_scrits/run_https.py`, qui génère des certificats auto-signés via `subprocess` ; - `docs/deployment.md`, une procédure manuelle ; - `backup.py`, dont le docstring évoque le Planificateur de tâches Windows — et qui ne fonctionne pas (MNT-07). `.gitignore:23` ignorant `docs/`, la documentation de déploiement est dans une situation ambiguë : présente dans l'index, mais toute mise à jour ultérieure risque de passer inaperçue (MNT-01). **Impact.** L'environnement de production n'est pas reproductible : il est le produit d'une suite d'actions manuelles sur une machine Windows particulière. Reconstruire l'installation après une panne matérielle demande de retrouver la bonne version de Python, d'installer nginx manuellement, de placer les certificats aux chemins attendus, de configurer les variables d'environnement et de créer les tâches planifiées — sans que rien ne vérifie que le résultat correspond à l'existant. Pour une association étudiante, où les personnes qui déploient changent chaque année, c'est un risque de continuité réel : la connaissance du déploiement n'est pas dans le dépôt. **Correction.** Un `Dockerfile` couvre l'essentiel du besoin et supprime la dépendance à Windows : ```dockerfile FROM python:3.12-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN useradd --create-home appuser && chown -R appuser /app USER appuser EXPOSE 5000 CMD ["python", "wsgi.py"] ``` avec un `docker-compose.yml` réunissant l'application, PostgreSQL et le bot Discord en service distinct (STD-03). Prérequis : corriger l'encodage de `requirements.txt` (MNT-02), sans quoi l'étape `pip install` peut échouer selon l'image de base ; et remplacer `psycopg2` par `psycopg2-binary` (MNT-05) pour éviter d'embarquer une chaîne de compilation. Si le déploiement doit rester sur Windows, alors documenter la procédure de façon exécutable — un script PowerShell d'installation versionné plutôt qu'une suite d'étapes en prose.