From fa0a37882747ea728effb62daea95b6fd7ac304e Mon Sep 17 00:00:00 2001 From: GGThed Date: Fri, 7 Aug 2026 12:56:13 -0400 Subject: [PATCH 01/75] 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 --- audit/01-securite.md | 672 ++++++++++++++++++++++++++++++++++++ audit/02-maintenabilite.md | 585 +++++++++++++++++++++++++++++++ audit/03-standards-stack.md | 393 +++++++++++++++++++++ audit/README.md | 69 ++++ audit/plan-remediation.md | 122 +++++++ 5 files changed, 1841 insertions(+) create mode 100644 audit/01-securite.md create mode 100644 audit/02-maintenabilite.md create mode 100644 audit/03-standards-stack.md create mode 100644 audit/README.md create mode 100644 audit/plan-remediation.md diff --git a/audit/01-securite.md b/audit/01-securite.md new file mode 100644 index 0000000..4750f60 --- /dev/null +++ b/audit/01-securite.md @@ -0,0 +1,672 @@ +# 1 — Sécurité + +19 constats. Les références de lignes correspondent à `main` @ `08f02f7`. + +| ID | Constat | Sévérité | +|---|---|---| +| [SEC-01](#sec-01--) | Secrets de production réels committés dans le dépôt | 🔴 Critique | +| [SEC-02](#sec-02--) | Seed automatique en production avec mot de passe `password` | 🔴 Critique | +| [SEC-03](#sec-03--) | CORS ouvert à toutes les origines avec credentials par défaut | 🔴 Critique | +| [SEC-04](#sec-04--) | Token du bot Discord imprimé sur stdout à l'import | 🔴 Critique | +| [SEC-05](#sec-05--) | En-têtes de proxy non validés → contournement HTTPS + rate limiting inopérant | 🟠 Élevé | +| [SEC-06](#sec-06--) | Schémas de validation importés mais jamais appliqués sur les routes utilisateurs | 🟠 Élevé | +| [SEC-07](#sec-07--) | `discord_user_id` arbitraire → détournement des notifications privées | 🟠 Élevé | +| [SEC-08](#sec-08--) | Rate limiting en mémoire, non partagé et réinitialisé à chaque redémarrage | 🟠 Élevé | +| [SEC-09](#sec-09--) | `nl2br` marque du HTML utilisateur non échappé comme sûr | 🟡 Moyen | +| [SEC-10](#sec-10--) | CSP avec `'unsafe-inline'` sur `script-src` | 🟡 Moyen | +| [SEC-11](#sec-11--) | Aucune validation de type sur l'upload de contrat signé | 🟡 Moyen | +| [SEC-12](#sec-12--) | Énumération d'utilisateurs via les messages de login | 🟡 Moyen | +| [SEC-13](#sec-13--) | CAPTCHA arithmétique trivial | 🟡 Moyen | +| [SEC-14](#sec-14--) | `/health` expose l'erreur brute de la base de données | 🟡 Moyen | +| [SEC-15](#sec-15--) | Profil complet de tout utilisateur visible par tout compte authentifié | 🟡 Moyen | +| [SEC-16](#sec-16--) | Conversions `int()` non protégées sur entrées utilisateur | 🔵 Faible | +| [SEC-17](#sec-17--) | `add_to_team` ne vérifie pas la cohérence tryout/équipe/joueur | 🔵 Faible | +| [SEC-18](#sec-18--) | Aucune réinitialisation de mot de passe ni MFA | 🔵 Faible | +| [SEC-19](#sec-19--) | Journal d'audit d'authentification déclaré mais jamais alimenté | 🔵 Faible | + +--- + +## SEC-01 · 🔴 + +**Secrets de production réels committés dans le dépôt** + +`app/.env.exemple` — fichier **suivi par git** — ne contient pas des valeurs d'exemple mais des identifiants réels : + +| Ligne | Secret | +|---|---| +| `:6` | `SECRET_KEY=65476749453935` — clé de signature des sessions Flask | +| `:18` | `DISCORD_BOT_TOKEN=MTUyNzY3ODU3NjUyNTA1NDEyNQ.G1gPNQ.LeFV…` — token du bot UdeS Esports | +| `:21` | `DATABASE_URL=postgresql://team_tryouts_db_user:0YO038Od2QcQ…@dpg-…render.com/team_tryouts_db` — base PostgreSQL Render, hôte public, avec mot de passe | + +Le commentaire ligne 16-17 confirme explicitement qu'il s'agit du token de production : *« This is the UdeS Esports BOT token »*. + +**Impact.** Toute personne ayant accès au dépôt — y compris via un fork, un clone, ou si le dépôt devient public — obtient : +- un accès lecture/écriture complet à la base de production (l'hôte Render est joignable depuis Internet) : identités, courriels, téléphones, notes personnelles des joueurs, contrats ; +- le contrôle du bot Discord (envoi de DM en usurpant l'identité de l'organisation) ; +- la capacité de **forger des cookies de session Flask valides** grâce à la `SECRET_KEY`, donc de s'authentifier en tant que n'importe quel utilisateur, y compris `admin`, sans mot de passe. + +Le `.gitignore` ignore bien `.env`, mais le fichier a été committé sous le nom `.env.exemple`, qui échappe à la règle. + +**Présent dans l'historique** depuis le commit `2d3721b` (*« Ajout d'un .env.exemple pour simplifier la collaboration »*). Supprimer le fichier ne suffira pas. + +**Correction.** +1. **Révoquer immédiatement, avant toute autre action** : régénérer le token du bot dans le Discord Developer Portal, faire tourner le mot de passe PostgreSQL sur Render, générer une nouvelle `SECRET_KEY` (`python -c "import secrets; print(secrets.token_hex(32))"`). La rotation de la `SECRET_KEY` invalidera toutes les sessions en cours, ce qui est le comportement souhaité ici. +2. Remplacer le contenu du fichier par des valeurs factices (`SECRET_KEY=`, `DATABASE_URL=postgresql://user:password@host:5432/dbname`). +3. Purger l'historique (`git filter-repo --path app/.env.exemple --invert-paths`, ou BFG), puis forcer la réécriture sur toutes les branches et prévenir les collaborateurs qu'ils doivent recloner. +4. Ajouter `.env*` (avec l'astérisque) au `.gitignore`, en gardant une exception explicite pour le modèle : `!.env.example`. +5. Ajouter un scan de secrets à la CI (`gitleaks`, ou `detect-secrets` en pre-commit) pour empêcher la récidive. + +> Renommer aussi le fichier en `.env.example` — l'orthographe actuelle est un francisme qui casse la détection automatique de la plupart des outils. + +--- + +## SEC-02 · 🔴 + +**Seed automatique en production avec mot de passe `password`** + +`app/app.py:348-356` + +```python +with app.app_context(): + import app.models as models + from app.models import User + db.create_all() + + if User.query.count() == 0: + from app.supporting_scrits.seed import seed_database + seed_database() +``` + +Ce bloc s'exécute **à chaque appel de `create_app()`**, sans distinction d'environnement — donc aussi via `wsgi.py`, c'est-à-dire en production. + +`app/supporting_scrits/seed.py` crée alors des comptes de démonstration dont le mot de passe est la chaîne littérale `password` : + +```python +username='admin', password_hash=hash_password('password'), # :42 +username='manager1', password_hash=hash_password('password'), # :48 +username='coach1', password_hash=hash_password('password'), # :60 +username='scout1', password_hash=hash_password('password'), # :79 +``` + +…et les affiche en clair au démarrage (`seed.py:449-453`). + +**Impact.** Tout déploiement neuf, toute restauration sur base vide, toute migration vers une nouvelle instance crée un compte `admin` / `password` accessible depuis Internet. C'est un contournement complet de l'authentification. Le compte `admin` a `can_manage_users() == True` : création, modification et suppression de tous les utilisateurs. + +Le mot de passe `password` ne respecte d'ailleurs pas la politique définie dans `validators.py:22-24` (8 caractères, majuscule, minuscule, chiffre) — ce qui montre que le seed contourne toute la couche de validation. + +**Correction.** +- Conditionner le seed : `if os.getenv('SEED_DEMO_DATA', 'false').lower() == 'true' and User.query.count() == 0:`. +- Mieux : sortir le seed du factory et en faire une commande CLI Flask (`flask seed-demo`), exécutée explicitement en développement. +- Faire générer les mots de passe de démo aléatoirement (`secrets.token_urlsafe(16)`) et les afficher une seule fois, plutôt que d'utiliser une constante. +- Vérifier immédiatement en production si les comptes `admin`, `manager1`, `manager2`, `coach1`, `coach2`, `coach3`, `scout1` existent avec ces mots de passe, et les désactiver le cas échéant. + +--- + +## SEC-03 · 🔴 + +**CORS ouvert à toutes les origines avec credentials par défaut** + +`app/app.py:76-95` + +```python +allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',') +allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()] + +if allowed_origins: + CORS(app, origins=allowed_origins, supports_credentials=True, …) +else: + # When no origins specified, allow all (development) or none (production) + # In production with a reverse proxy, CORS is handled at the Nginx level + CORS(app, supports_credentials=True, methods=[…], max_age=3600) +``` + +Le commentaire décrit une intention qui n'est pas implémentée : la branche `else` **autorise toutes les origines**, en développement comme en production. `flask-cors` avec `supports_credentials=True` et sans `origins` reflète l'en-tête `Origin` de la requête dans `Access-Control-Allow-Origin` et ajoute `Access-Control-Allow-Credentials: true`. + +Le commentaire renvoie la responsabilité à nginx, mais `app/nginx.conf` **ne contient aucune directive CORS**. Et `app/.env.exemple` **ne définit pas `CORS_ALLOWED_ORIGINS`** : la configuration livrée aux équipes tombe donc systématiquement dans la branche permissive. + +**Impact.** N'importe quel site tiers visité par un utilisateur connecté peut lire, avec ses cookies de session, le contenu de toutes les routes `GET` — notamment : +- `/users/disponibilities` : disponibilités de tous les joueurs actifs, avec noms d'utilisateur ; +- `/matches/api/events` : calendrier complet, participants, sessions 1:1 approuvées ; +- `/users/profile`, `/users//view` : données personnelles. + +La protection CSRF (`CSRFProtect`) limite les écritures, mais n'empêche pas ces lectures. + +**Correction.** + +```python +allowed_origins = [o.strip() for o in os.getenv('CORS_ALLOWED_ORIGINS', '').split(',') if o.strip()] +if allowed_origins: + CORS(app, origins=allowed_origins, supports_credentials=True, methods=[...], max_age=3600) +elif app.debug: + CORS(app, origins=['http://localhost:5000'], supports_credentials=True) +# sinon : pas de CORS du tout — même origine uniquement +``` + +L'application étant rendue côté serveur (Jinja2) et consommant ses propres API en même-origine, **le cas nominal est de ne pas activer CORS du tout**. Documenter `CORS_ALLOWED_ORIGINS` dans le fichier d'exemple. + +--- + +## SEC-04 · 🔴 + +**Token du bot Discord imprimé sur stdout à l'import** + +`app/discord_bot.py:24-25` + +```python +DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') +print(DISCORD_BOT_TOKEN or 'FAILED TO PRINT BOT TOKEN') +``` + +Le token est écrit en clair sur la sortie standard **à chaque import du module**, donc à chaque démarrage de l'application. + +**Impact.** Le secret se retrouve dans les logs du superviseur de processus, les journaux de la plateforme d'hébergement, les logs de conteneur, et la sortie des jobs CI. Ces destinations sont typiquement conservées longtemps, indexées, et accessibles à un public plus large que les variables d'environnement elles-mêmes. + +À noter : le `SensitiveDataFilter` de `logging_config.py` ne peut rien ici — il filtre les enregistrements du module `logging`, pas les appels à `print()`. + +**Correction.** Supprimer la ligne. Si un diagnostic de configuration est nécessaire au démarrage : + +```python +logger.info('Discord bot token: %s', 'configuré' if DISCORD_BOT_TOKEN else 'ABSENT') +``` + +--- + +## SEC-05 · 🟠 + +**En-têtes de proxy non validés → contournement HTTPS et rate limiting inopérant** + +L'application lit `X-Forwarded-Proto` pour décider d'appliquer HSTS et la redirection HTTPS : + +`app/app.py:163` — `is_https = request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'` +`app/app.py:182` — `if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https':` + +Or **`ProxyFix` n'est jamais appliqué** et aucune liste de proxys de confiance n'est configurée. L'en-tête est accepté tel quel, quelle que soit sa provenance. + +Ce défaut est amplifié par la configuration réseau : + +- `wsgi.py:26` — `host = os.getenv('HOST', '0.0.0.0')`, avec le commentaire trompeur *« Bind to localhost by default »*. Le serveur Waitress écoute en réalité sur **toutes les interfaces**. +- `app/nginx.conf:122` — `proxy_pass http://0.0.0.0:5000;` — `0.0.0.0` n'est pas une adresse de destination valide comme cible amont ; ce devrait être `127.0.0.1`. + +**Impact.** +1. Le port de l'application est joignable directement, en contournant nginx — donc sans TLS, sans les en-têtes de sécurité ajoutés par nginx. +2. En envoyant `X-Forwarded-Proto: https` sur cette connexion en clair, on désactive la redirection HTTPS de `force_https()` et l'application se comporte comme si la connexion était sécurisée. +3. **Corollaire plus grave — le rate limiting est neutralisé.** `app/extensions.py:16-19` utilise `key_func=get_remote_address`, qui lit `request.remote_addr`. Sans `ProxyFix`, cette valeur est l'IP de nginx pour *toutes* les requêtes proxifiées. Conséquences : + - la limite de `10 per minute` sur `/auth/login` (`auth.py:79`) devient un **seau global partagé par tous les utilisateurs** — la protection anti-bruteforce ne fonctionne pas par attaquant ; + - inversement, un seul client peut consommer le quota global et **bloquer le login de toute l'organisation** (déni de service trivial) ; + - les limites par défaut `200/jour, 50/heure` s'appliquent à l'ensemble du trafic, ce qui rendra l'application inutilisable en usage normal dès quelques utilisateurs simultanés. + +**Correction.** + +```python +from werkzeug.middleware.proxy_fix import ProxyFix + +# après la création de l'app, uniquement si l'on est réellement derrière un proxy +if os.getenv('BEHIND_PROXY', 'false').lower() == 'true': + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) +``` + +`x_for=1` indique de ne faire confiance qu'au dernier saut — celui de nginx. Ne jamais activer ce middleware si l'application n'est pas derrière un proxy, sinon `X-Forwarded-For` devient falsifiable par le client. + +En complément : +- `wsgi.py` : passer le défaut de `HOST` à `127.0.0.1` (ce que le commentaire annonce déjà) ; +- `nginx.conf:122` : `proxy_pass http://127.0.0.1:5000;` ; +- filtrer au pare-feu le port applicatif. + +--- + +## SEC-06 · 🟠 + +**Schémas de validation importés mais jamais appliqués sur les routes utilisateurs** + +`app/routes/users.py:23-26` importe `CreateUserSchema`, `EditUserSchema` et `EditProfileSchema`. Vérification par comptage d'occurrences : **chacun de ces trois noms n'apparaît qu'une seule fois dans le fichier — sur la ligne d'import**. Ils ne sont jamais instanciés. + +Les trois routes concernées lisent le formulaire brut : + +| Route | Lignes | Traitement | +|---|---|---| +| `create_user` | `:196-226` | `request.form.get('password')` → `hash_password(password)` directement | +| `edit_user` | `:98-133` | `password = request.form.get('password')` → `hash_password(password)` si non vide | +| `edit_profile` | `:255-295` | idem, sur son propre compte | + +Comparaison avec `auth.py:206-218`, où `RegisterSchema` **est** correctement chargé — l'inscription publique est donc validée, mais pas les trois autres chemins de création/modification de compte. + +**Impact.** +- **Aucune politique de mot de passe** sur ces routes : `a` est accepté. Un président créant les comptes de l'équipe peut leur attribuer des mots de passe d'un caractère, et n'importe quel utilisateur peut affaiblir le sien via `edit_profile`. +- **Aucune validation de format** sur `email` (le champ n'est même pas vérifié comme étant une adresse), `username`, `phone`. +- `create_user` (`:216`) appelle `hash_password(password)` sans vérifier que `password` est non vide : un `password_hash` d'une chaîne vide est stocké, et le compte devient accessible avec un mot de passe vide. +- Dans `edit_user` (`:115-118`), `full_name` et `email` sont assignés sans contrôle de nullité, alors que les colonnes sont `nullable=False` (`models/user_model/user.py:20-21`) → `IntegrityError` non gérée → 500. + +**Correction.** Appliquer les schémas déjà écrits, sur le modèle de `auth.py` : + +```python +schema = EditProfileSchema() +try: + validated = schema.load(request.form) +except ValidationError as err: + for field, messages in err.messages.items(): + for msg in messages: + flash(f'{field}: {msg}', 'danger') + return render_template('pages/edit_profile.html', ...) +``` + +Attention : `EditUserSchema` et `EditProfileSchema` déclarent `password` avec `load_default=''` et `validate=validate_password` — un mot de passe vide échouera donc la validation. Il faut soit passer `validate=validate.And(...)` conditionnel, soit retirer le champ du payload quand il est vide avant le `load()`. + +--- + +## SEC-07 · 🟠 + +**`discord_user_id` arbitraire → détournement des notifications privées** + +`app/routes/users.py:262-263` et `:283-284` (route `edit_profile`, accessible à **tout utilisateur authentifié**) : + +```python +discord_user_id = request.form.get('discord_user_id', '').strip() +... +current_user.discord_user_id = discord_user_id or None +``` + +Aucune validation (conséquence de SEC-06 : `validate_discord_user_id` existe dans `validators.py:83-97` mais n'est pas appelée), et **aucune vérification de propriété** : rien ne prouve que l'utilisateur contrôle réellement ce compte Discord. Aucune contrainte d'unicité sur la colonne non plus (`models/user_model/user.py:32`). + +**Impact.** Un joueur peut renseigner l'identifiant Discord d'une autre personne — un coach, un membre de la direction. Il reçoit alors à sa place les messages privés du bot. Selon les flux décrits dans le README, cela inclut : +- les demandes de sessions 1:1 avec leurs *« discussion points »*, souvent confidentiels ; +- les notifications de matchs et d'entraînements ; +- surtout, **la capacité de répondre à la place de la cible** : `discord_bot.py` traite les réactions ✅/❌ en DM pour accepter ou refuser une demande 1:1 (`on_reaction_add`, `:118`). L'attaquant obtient donc un pouvoir de décision qui ne lui appartient pas. + +Deux utilisateurs peuvent en outre déclarer le même identifiant, ce qui rend le comportement non déterministe. + +**Correction.** +1. Appliquer `validate_discord_user_id` (corrigé par SEC-06) — nécessaire mais très insuffisant : il ne vérifie que le format 17-20 chiffres. +2. Ajouter une contrainte d'unicité sur `User.discord_user_id`. +3. **Implémenter une vérification de possession** : à la saisie, envoyer un code à usage unique en DM sur l'identifiant déclaré et exiger sa saisie sur la plateforme avant d'activer le lien. C'est la seule correction qui traite réellement le problème. +4. En attendant, réserver la modification de ce champ aux administrateurs. + +--- + +## SEC-08 · 🟠 + +**Rate limiting en mémoire, non partagé et réinitialisé à chaque redémarrage** + +`app/extensions.py:16-19` + +```python +limiter = Limiter( + key_func=get_remote_address, + default_limits=["200 per day", "50 per hour"] +) +``` + +Aucun `storage_uri` n'est fourni. Flask-Limiter bascule alors sur son backend `memory://`, qui est explicitement documenté comme non destiné à la production (la bibliothèque émet d'ailleurs un avertissement au démarrage). + +**Impact.** +- L'état est **par processus**. `wsgi.py:25` démarre Waitress avec `cpu_count() * 2 + 1` threads — cela reste un processus, donc le compteur est partagé ici ; mais toute évolution vers plusieurs workers ou plusieurs instances (montée en charge, déploiement bleu-vert) fragmente les compteurs et multiplie d'autant la limite effective. +- L'état est **perdu à chaque redémarrage** : un attaquant peut réinitialiser les compteurs si un redéploiement survient, et le verrouillage anti-bruteforce ne survit pas aux mises à jour. +- Combiné à SEC-05, la protection est de toute façon appliquée à la mauvaise clé. + +**Correction.** Adosser le limiteur à un stockage partagé — Redis de préférence, ou la base PostgreSQL déjà présente si l'on veut éviter une dépendance supplémentaire : + +```python +limiter = Limiter( + key_func=get_remote_address, + default_limits=["200 per day", "50 per hour"], + storage_uri=os.getenv('RATELIMIT_STORAGE_URI', 'memory://'), +) +``` + +Réévaluer aussi les valeurs : `50 per hour` par IP est très bas pour une application web rendue côté serveur, où chaque page consomme plusieurs requêtes (`/matches/api/events`, `/users/disponibilities`…). Exclure les routes `/static` et `/health` du décompte. + +--- + +## SEC-09 · 🟡 + +**`nl2br` marque du HTML utilisateur non échappé comme sûr** + +`app/app.py:19-30` + +```python +def nl2br(value): + if value: + return markupsafe.Markup('
'.join(str(value).splitlines())) + return '' +``` + +`markupsafe.Markup()` **désactive l'échappement automatique de Jinja2** pour la chaîne produite. Le contenu utilisateur est inséré tel quel, sans passer par `escape()`. + +**Statut actuel : non exploitable.** Une recherche sur l'ensemble des templates ne trouve **aucune utilisation de `|nl2br`**. Le filtre est enregistré (`app.py:125`) mais mort. + +**Risque.** C'est un piège en attente : le filtre porte un nom naturel, il est enregistré globalement, et la première personne qui écrira `{{ note.content|nl2br }}` — un usage évident sur `PersonalNote`, `TeamNote` ou les *discussion points* des 1:1 — introduira une XSS stockée sans s'en rendre compte. Ces contenus sont saisis par des coachs et joueurs et affichés à d'autres utilisateurs. + +**Correction.** Échapper avant de marquer : + +```python +from markupsafe import Markup, escape + +def nl2br(value): + if not value: + return '' + return Markup('
').join(escape(str(value)).splitlines()) +``` + +`escape()` neutralise le HTML utilisateur ; seuls les `
` insérés par le filtre restent actifs. Alternative sans code : supprimer le filtre et utiliser `white-space: pre-line` en CSS. + +--- + +## SEC-10 · 🟡 + +**CSP avec `'unsafe-inline'` sur `script-src`** + +`app/app.py:149-159` + +```python +"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " +"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; " +``` + +`'unsafe-inline'` sur `script-src` **annule l'essentiel du bénéfice de la CSP** : c'est précisément l'injection de `') rend desormais <script>alert(1)</script>. Filtre de redaction des secrets sans effet SensitiveDataFilter n'inspectait que record.msg. Or le code journalise en style parametre ('...: %s', valeur) : record.msg ne contient que la chaine de format, et la donnee sensible vit dans record.args, ignore. La redaction ne s'appliquait donc pratiquement jamais. Le record est desormais rendu avant filtrage, puis args vide. Sortie console conditionnee a FLASK_DEBUG En production, l'application n'ecrivait rien sur stdout, precisement ou regarde la console Pterodactyl. Le handler devient inconditionnel, seul son niveau varie. Journaux du bot Discord perdus discord_bot.py utilise getLogger(__name__), soit 'app.discord_bot'. Aucun handler n'etait attache a la hierarchie 'app' : les INFO etaient jetes et les WARNING+ tombaient sur le handler de dernier recours, sans format. Les handlers sont desormais rattaches au logger de paquet. X-XSS-Protection retire (app.py et nginx.conf) En-tete deprecie, l'auditeur vise a ete supprime des navigateurs courants et ses dernieres implementations introduisaient elles-memes des vulnerabilites. Co-Authored-By: Claude Opus 5 --- app/app.py | 17 +++++++++--- app/logging_config.py | 61 ++++++++++++++++++++++++++++++------------- app/nginx.conf | 3 ++- 3 files changed, 58 insertions(+), 23 deletions(-) diff --git a/app/app.py b/app/app.py index f15ffa3..a8fab15 100644 --- a/app/app.py +++ b/app/app.py @@ -26,7 +26,9 @@ def nl2br(value): Markup: HTML-safe string with line breaks. """ if value: - return markupsafe.Markup('
'.join(str(value).splitlines())) + # Markup('
').join() escapes each segment before joining. + # Markup('
'.join(...)) would mark attacker-controlled text as safe. + return markupsafe.Markup('
').join(str(value).splitlines()) return '' @@ -137,9 +139,12 @@ def create_app(): HSTS is only sent in production (non-debug) to avoid breaking local development over plain HTTP. """ + # X-XSS-Protection is deliberately not set: the auditor it addressed + # has been removed from every current browser, and its last versions + # introduced vulnerabilities of their own. CSP frame-ancestors and + # X-Frame-Options cover the remaining ground. response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'DENY' - response.headers['X-XSS-Protection'] = '1; mode=block' response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers['Permissions-Policy'] = ( 'camera=(), microphone=(), geolocation=(), ' @@ -206,9 +211,13 @@ def create_app(): try: db.session.execute(text('SELECT 1')) health_data['database'] = 'connected' - except Exception as e: + except Exception: + # Never echo the driver error: it routinely carries the host, + # database name and user of the connection string, and /health + # is unauthenticated. + app.logger.error('Health check: database unreachable', exc_info=True) health_data['status'] = 'unhealthy' - health_data['database'] = f'error: {str(e)}' + health_data['database'] = 'error' return jsonify(health_data), 503 return jsonify(health_data), 200 diff --git a/app/logging_config.py b/app/logging_config.py index 311c5e9..163d798 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -31,22 +31,31 @@ class SensitiveDataFilter(logging.Filter): ] def filter(self, record): - """Apply redaction to the log record's message. - + """Apply redaction to the log record's fully rendered message. + + The record is rendered first (msg % args) and the result stored back + as msg with args cleared. Redacting record.msg alone would miss almost + everything: this codebase logs with %s placeholders, so the sensitive + value lives in record.args while record.msg holds only the format + string. + Args: record: The log record to filter. - + Returns: bool: Always True (never drops records, only redacts). """ - if hasattr(record, 'msg') and isinstance(record.msg, str): - msg = record.msg - for pattern, replacement in self.SENSITIVE_PATTERNS: - if callable(replacement): - msg = pattern.sub(replacement, msg) - else: - msg = pattern.sub(replacement, msg) - record.msg = msg + try: + rendered = record.getMessage() + except Exception: + # A malformed format string must not lose the record entirely. + return True + + for pattern, replacement in self.SENSITIVE_PATTERNS: + rendered = pattern.sub(replacement, rendered) + + record.msg = rendered + record.args = () return True @@ -128,14 +137,30 @@ def configure_logging(app): app.logger.addHandler(app_handler) # ------------------------------------------------------------------------- - # 4. Console Handler (for development) + # 4. Console Handler (always on) # ------------------------------------------------------------------------- - if os.getenv('FLASK_DEBUG', 'false').lower() == 'true': - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.DEBUG) - console_handler.setFormatter(formatter) - console_handler.addFilter(sensitive_filter) - app.logger.addHandler(console_handler) + # Previously gated on FLASK_DEBUG, which meant production emitted nothing + # on stdout — precisely where the Pterodactyl console looks. Keep the + # handler unconditional and vary the level instead. + debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true' + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG if debug_mode else log_level) + console_handler.setFormatter(formatter) + console_handler.addFilter(sensitive_filter) + app.logger.addHandler(console_handler) + + # ------------------------------------------------------------------------- + # 5. Package logger ('app.*') — notably app.discord_bot + # ------------------------------------------------------------------------- + # Modules using logging.getLogger(__name__) resolve to 'app.'. + # Without handlers here their INFO records were dropped entirely and + # WARNING+ fell through to Python's lastResort handler, unformatted. + package_logger = logging.getLogger('app') + package_logger.setLevel(log_level) + package_logger.propagate = False + for handler in (error_handler, app_handler, console_handler): + if handler not in package_logger.handlers: + package_logger.addHandler(handler) # Log startup information app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir) diff --git a/app/nginx.conf b/app/nginx.conf index 90b22e0..71c10ff 100644 --- a/app/nginx.conf +++ b/app/nginx.conf @@ -110,7 +110,8 @@ http { add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; - add_header X-XSS-Protection "1; mode=block" always; + # X-XSS-Protection intentionally omitted: deprecated, removed from + # current browsers, and harmful in its last implementations. add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; add_header Cross-Origin-Opener-Policy "same-origin" always; From 1b990a84d9aceb9bb6584d3d1a0c443bef5f6bb9 Mon Sep 17 00:00:00 2001 From: GGThed Date: Fri, 7 Aug 2026 19:46:34 -0400 Subject: [PATCH 07/75] test: socle de tests executables et fabrique d'application parametrable Il n'existait aucun test, et le code n'offrait aucune prise pour en ecrire : create_app() exigeait SECRET_KEY et DATABASE_URL dans l'environnement, creait les tables et demarrait un bot Discord. C'etait la cause, pas le symptome. create_app(config=None) Les valeurs par defaut viennent toujours de l'environnement, les surcharges de l'appelant sont appliquees ensuite, et la validation vient en dernier pour qu'un test puisse fournir les siennes. Deux effets de bord passent sous drapeau, actifs par defaut pour que la production et le developpement se comportent a l'identique : AUTO_CREATE_TABLES controle db.create_all() ENABLE_DISCORD_BOT controle start_bot() FORCE_HTTPS passe egalement en configuration : lu via os.getenv a chaque requete, il renvoyait un 301 sur tout appel de test. Suite de tests : 47 tests, 3 xfail, 32 % de couverture. tests/conftest.py fabriques par role, connexion par le vrai formulaire, base SQLite temporaire test_auth_session.py expiration de session, desactivation de compte, deconnexion test_security_headers.py en-tetes, non-divulgation sur /health, echappement de nl2br test_authorization.py acces anonyme, vertical, horizontal, validation des entrees, CSRF Les tests marques xfail(strict=True) decrivent des constats non encore corriges. Ils echouent par construction ; le mode strict transforme une reussite inattendue en echec, ce qui signale qu'il faut retirer le marqueur. Trois subsistent : enumeration de comptes (SEC-AUTH-006), CSP unsafe-inline (SEC-WEB-001), auto-retrogradation du dernier administrateur (SEC-AUTHZ-007). pyproject.toml Configuration pytest et ruff. Ruff n'avait aucune configuration : la CI l'executait avec le jeu de regles par defaut. Les 33 F401 de app/models/__init__.py sont ignores par fichier, c'est une facade de re-export intentionnelle. requirements-dev.txt separe l'outillage de test des dependances de production. Co-Authored-By: Claude Opus 5 --- app/app.py | 62 ++++-- pyproject.toml | 42 +++++ requirements-dev.txt | 13 ++ tests/conftest.py | 173 +++++++++++++++++ tests/test_auth_session.py | 132 +++++++++++++ tests/test_authorization.py | 333 +++++++++++++++++++++++++++++++++ tests/test_security_headers.py | 82 ++++++++ 7 files changed, 821 insertions(+), 16 deletions(-) create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_auth_session.py create mode 100644 tests/test_authorization.py create mode 100644 tests/test_security_headers.py diff --git a/app/app.py b/app/app.py index a8fab15..8a603f2 100644 --- a/app/app.py +++ b/app/app.py @@ -7,7 +7,7 @@ the Flask application instance with comprehensive security hardening. import os from flask import Flask, request, redirect, jsonify, render_template, url_for from flask_cors import CORS -from app.extensions import db, login_manager, csrf, hash_password, check_password, limiter +from app.extensions import db, login_manager, csrf, limiter from sqlalchemy import text from werkzeug.exceptions import HTTPException import markupsafe @@ -32,9 +32,15 @@ def nl2br(value): return '' -def create_app(): +def create_app(config=None): """Create and configure the Flask application. + Args: + config: Optional mapping of configuration overrides, applied after the + environment defaults and before validation. This is what makes the + factory usable from tests: pass a throwaway database URI, a dummy + secret, and turn off the Discord bot, without touching os.environ. + Initializes Flask with: - Secret key for session security - Database configuration @@ -54,16 +60,35 @@ def create_app(): Flask: Configured Flask application instance. """ app = Flask(__name__) + + # --- defaults from the environment ------------------------------------ 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['WTF_CSRF_ENABLED'] = True + app.config['CORS_ALLOWED_ORIGINS'] = os.getenv('CORS_ALLOWED_ORIGINS', '') + app.config['FORCE_HTTPS'] = os.getenv('FORCE_HTTPS', 'true').lower() == 'true' + + # Side effects of create_app(), both on by default so that production and + # development behave exactly as before. Tests turn them off. + app.config['AUTO_CREATE_TABLES'] = ( + os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true' + ) + app.config['ENABLE_DISCORD_BOT'] = ( + os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true' + ) + + # --- caller overrides win --------------------------------------------- + if config: + app.config.update(config) + + # --- validation, after overrides so tests can supply their own --------- if not app.config['SECRET_KEY']: raise RuntimeError('SECRET_KEY environment variable must be set for security') - app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') if not app.config['SQLALCHEMY_DATABASE_URI']: raise RuntimeError( 'DATABASE_URL environment variable must be set to a PostgreSQL connection string' ) - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config['WTF_CSRF_ENABLED'] = True # File upload size limit (16 MB) app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 @@ -75,9 +100,9 @@ def create_app(): app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout # Configure CORS - restrict to specific origins in production - allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',') + allowed_origins = str(app.config.get('CORS_ALLOWED_ORIGINS') or '').split(',') allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()] - + if allowed_origins: CORS( app, @@ -183,10 +208,9 @@ def create_app(): Respects the X-Forwarded-Proto header from reverse proxies. Can be disabled via FORCE_HTTPS environment variable. """ - if not app.debug: + if not app.debug and app.config['FORCE_HTTPS']: if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https': - if os.getenv('FORCE_HTTPS', 'true').lower() == 'true': - return redirect(request.url.replace('http://', 'https://'), code=301) + return redirect(request.url.replace('http://', 'https://'), code=301) # ========================================================================= # Health Check Endpoint @@ -356,14 +380,20 @@ def create_app(): # ========================================================================= with app.app_context(): import app.models as models # noqa: F401 — registers all models with SQLAlchemy - db.create_all() + # NOTE: create_all() only ever creates missing tables. It never adds a + # column to an existing one, so a model change is silently absent from + # any database that already has the table. Replacing this with Alembic + # is tracked as DB-002/DB-004; until then the behaviour is preserved. + if app.config['AUTO_CREATE_TABLES']: + db.create_all() # Start the Discord bot for notifications - 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) + if app.config['ENABLE_DISCORD_BOT']: + 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) return app diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..11d0acd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +# Tooling configuration. +# +# Deliberately limited to tool settings: the project is run from wsgi.py, +# not installed as a distribution, so there is no [project] table yet. +# Consolidating requirements.txt / requirements-dev.txt into dependency +# groups here is tracked as QUA-001. + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "-q --strict-markers --strict-config" +filterwarnings = [ + "default", + # discord.py imports audioop, removed from the stdlib in 3.13. + "ignore:'audioop' is deprecated:DeprecationWarning", + # Every model uses datetime.utcnow as a column default. Tracked as + # DB-009; the warning would otherwise drown the run. + "ignore:datetime.datetime.utcnow:DeprecationWarning", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" +exclude = [".venv", "venv", "migrations", "docs"] + +[tool.ruff.lint] +# Starting from ruff's default rule set (pyflakes + a slice of pycodestyle). +# Widening it — bugbear, isort, pyupgrade — is deliberately deferred until +# the codebase has been formatted once, so that the first enforcement is +# about real defects rather than churn. Tracked as QUA-002. +select = ["E4", "E7", "E9", "F"] + +[tool.ruff.lint.per-file-ignores] +# Intentional re-export facade: `from app.models import User, Tryout, ...` +# is the documented entry point, and importing the modules is what +# registers every model with SQLAlchemy. +"app/models/__init__.py" = ["F401"] +"app/models/user_model/__init__.py" = ["F401"] +"tests/conftest.py" = ["E402"] + +[tool.ruff.format] +quote-style = "preserve" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..73f32a2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,13 @@ +# Development and test dependencies. +# Install with: pip install -r requirements.txt -r requirements-dev.txt +# +# Kept separate from requirements.txt so that a production install stays +# free of test tooling. Consolidating both into a pyproject.toml with +# dependency groups is tracked as QUA-001. + +-r requirements.txt + +pytest==8.4.2 +pytest-cov==7.0.0 +ruff==0.14.4 +pip-audit==2.9.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cfa021b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,173 @@ +"""Shared pytest fixtures for the Team Tryouts test suite. + +The application factory is driven entirely through the ``config`` argument +here: no environment variable is required to run the suite, no database +server is needed, and the Discord bot never starts. +""" + +import os +import sys +import tempfile + +import pytest + +# Make the project root importable as the 'app' package. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.app import create_app # noqa: E402 +from app.extensions import db as _db # noqa: E402 +from app.models import Admin, Coach, Manager, Player, Scout # noqa: E402 + + +ROLE_CLASSES = { + 'admin': Admin, + 'manager': Manager, + 'coach': Coach, + 'player': Player, + 'scout': Scout, +} + +#: Satisfies the documented policy (8+ chars, upper, lower, digit). +VALID_PASSWORD = 'Password123' + + +def _base_config(db_path, csrf=False): + return { + 'SECRET_KEY': 'test-secret-not-used-anywhere-real', + 'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}', + 'TESTING': True, + 'WTF_CSRF_ENABLED': csrf, + # Without these three the suite would 301 every request, start a + # Discord bot, and refuse to issue cookies over the test client. + 'FORCE_HTTPS': False, + 'SESSION_COOKIE_SECURE': False, + 'ENABLE_DISCORD_BOT': False, + # The schema still comes from create_all() until Alembic lands (DB-002). + 'AUTO_CREATE_TABLES': True, + 'CORS_ALLOWED_ORIGINS': '', + 'RATELIMIT_ENABLED': False, + } + + +@pytest.fixture +def app(): + """A fully configured application backed by a throwaway SQLite file. + + A file rather than :memory: because Flask-SQLAlchemy hands out a + connection per thread, and an in-memory database is not shared between + them — tables created on one connection would be invisible to the next. + """ + fd, db_path = tempfile.mkstemp(suffix='.sqlite') + os.close(fd) + + application = create_app(_base_config(db_path)) + + yield application + + with application.app_context(): + _db.session.remove() + _db.engine.dispose() + try: + os.unlink(db_path) + except OSError: + pass + + +@pytest.fixture +def app_with_csrf(): + """Same application, with CSRF protection left switched on.""" + fd, db_path = tempfile.mkstemp(suffix='.sqlite') + os.close(fd) + + application = create_app(_base_config(db_path, csrf=True)) + + yield application + + with application.app_context(): + _db.session.remove() + _db.engine.dispose() + try: + os.unlink(db_path) + except OSError: + pass + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def db(app): + """Database handle bound to an active application context.""" + with app.app_context(): + yield _db + + +@pytest.fixture +def make_user(app): + """Factory creating a user of a given role and returning its id. + + Returns the primary key rather than the instance: the object would be + detached once the fixture's application context is popped, and every + caller wants to look it up inside its own context anyway. + """ + counter = {'n': 0} + + def _make(role='player', password=VALID_PASSWORD, **kwargs): + from app.extensions import hash_password + + counter['n'] += 1 + n = counter['n'] + cls = ROLE_CLASSES[role] + with app.app_context(): + user = cls( + username=kwargs.pop('username', f'{role}{n}'), + password_hash=hash_password(password), + role=role, + full_name=kwargs.pop('full_name', f'{role.title()} {n}'), + email=kwargs.pop('email', f'{role}{n}@example.test'), + **kwargs, + ) + _db.session.add(user) + _db.session.commit() + return user.id + + return _make + + +@pytest.fixture +def login(client): + """Log a user in through the real login form. + + Deliberately exercises the actual authentication path rather than + poking flask_login's session key, so that session handling itself + stays under test. + """ + + def _login(username, password=VALID_PASSWORD): + return client.post( + '/auth/login', + data={'username': username, 'password': password}, + follow_redirects=False, + ) + + return _login + + +@pytest.fixture +def as_role(app, client, make_user, login): + """Create a user of the given role, log in, and return its id.""" + + def _as(role='player', **kwargs): + user_id = make_user(role, **kwargs) + with app.app_context(): + from app.models import User + username = _db.session.get(User, user_id).username + response = login(username) + assert response.status_code in (301, 302), ( + f'login for {username} did not redirect: {response.status_code}' + ) + return user_id + + return _as diff --git a/tests/test_auth_session.py b/tests/test_auth_session.py new file mode 100644 index 0000000..8a2a8aa --- /dev/null +++ b/tests/test_auth_session.py @@ -0,0 +1,132 @@ +"""Session lifecycle and account state. + +These lock in the two fixes from wave 0: sessions now actually expire, and +deactivating an account now closes the sessions it already holds. +""" + +import pytest + +from app.extensions import db +from app.models import User + + +def _set_active(app, user_id, active): + with app.app_context(): + user = db.session.get(User, user_id) + user.is_active_account = active + db.session.commit() + + +class TestSessionExpiry: + def test_login_issues_an_expiring_session_cookie(self, app, client, as_role): + """PERMANENT_SESSION_LIFETIME only applies to permanent sessions. + + Before the fix, session.permanent was never set anywhere in app/, + so Flask emitted a browser-session cookie with no Expires attribute + and the configured one-hour lifetime was silently ignored. + """ + as_role('player') + + cookie = client.get_cookie('session') + assert cookie is not None, 'no session cookie was issued at login' + assert cookie.expires is not None, ( + 'session cookie has no expiry: session.permanent was not set, ' + 'so PERMANENT_SESSION_LIFETIME has no effect' + ) + + def test_session_lifetime_matches_configuration(self, app): + from datetime import timedelta + + assert app.permanent_session_lifetime == timedelta(seconds=3600) + + +class TestAccountDeactivation: + def test_deactivated_account_cannot_log_in(self, app, client, make_user, login): + user_id = make_user('player') + _set_active(app, user_id, False) + + with app.app_context(): + username = db.session.get(User, user_id).username + + login(username) + response = client.get('/users/profile', follow_redirects=False) + assert response.status_code in (301, 302) + assert '/auth/login' in response.headers.get('Location', '') + + def test_deactivated_account_loses_its_existing_session(self, app, client, as_role): + """The fix that matters: revocation has to reach live sessions. + + is_active_account used to be consulted only at login. User did not + override UserMixin.is_active, so Flask-Login treated every account + as active, and disabling someone merely stopped them reconnecting — + their open session kept working. + """ + user_id = as_role('player') + + assert client.get('/users/profile').status_code == 200 + + _set_active(app, user_id, False) + + response = client.get('/users/profile', follow_redirects=False) + assert response.status_code in (301, 302), ( + 'a deactivated account kept access with its existing session' + ) + assert '/auth/login' in response.headers.get('Location', '') + + def test_is_active_property_tracks_the_column(self, app, make_user): + user_id = make_user('coach') + with app.app_context(): + user = db.session.get(User, user_id) + assert user.is_active is True + user.is_active_account = False + assert user.is_active is False + + +class TestLogout: + def test_logout_ends_the_session(self, client, as_role): + as_role('player') + assert client.get('/users/profile').status_code == 200 + + client.get('/auth/logout') + + response = client.get('/users/profile', follow_redirects=False) + assert response.status_code in (301, 302) + assert '/auth/login' in response.headers.get('Location', '') + + +class TestLoginRejection: + def test_wrong_password_is_refused(self, client, make_user, login, app): + user_id = make_user('player') + with app.app_context(): + username = db.session.get(User, user_id).username + + login(username, password='WrongPassword1') + + response = client.get('/users/profile', follow_redirects=False) + assert response.status_code in (301, 302) + + @pytest.mark.xfail( + strict=True, + reason='SEC-AUTH-006: the two branches emit different messages, ' + 'which lets an unauthenticated caller enumerate accounts', + ) + def test_login_failure_message_does_not_reveal_account_existence( + self, client, make_user, app + ): + user_id = make_user('player') + with app.app_context(): + username = db.session.get(User, user_id).username + + existing = client.post( + '/auth/login', + data={'username': username, 'password': 'WrongPassword1'}, + follow_redirects=True, + ).get_data(as_text=True) + + unknown = client.post( + '/auth/login', + data={'username': 'no-such-account', 'password': 'WrongPassword1'}, + follow_redirects=True, + ).get_data(as_text=True) + + assert ('attempt(s) remaining' in existing) == ('attempt(s) remaining' in unknown) diff --git a/tests/test_authorization.py b/tests/test_authorization.py new file mode 100644 index 0000000..bf6d860 --- /dev/null +++ b/tests/test_authorization.py @@ -0,0 +1,333 @@ +"""Access control regression tests. + +Two kinds of test live here. + +Passing tests pin down behaviour that is currently correct, so that the +architecture work in wave D — unifying the two coach/team models — cannot +quietly break it. + +Tests marked xfail(strict=True) describe behaviour the audit found missing. +They fail today by design and will start passing when the matching finding +is fixed; strict mode then turns the unexpected pass into a failure, which +is the signal to remove the marker. They are executable documentation of +the gap, not a wish list. +""" + +import pytest + +from app.extensions import db +from app.models import User + +#: Routes that must never answer to an unauthenticated caller. +PROTECTED_ROUTES = [ + '/users', + '/users/create', + '/users/profile', + '/users/contracts', + '/tryouts', + '/teams', + '/evaluations', + '/matches/calendar', + '/team-matches', +] + +#: Admin-only user management surface. +ADMIN_ONLY_ROUTES = [ + '/users', + '/users/create', +] + + +def _redirected(response): + return response.status_code in (301, 302) + + +def _username(app, user_id): + with app.app_context(): + return db.session.get(User, user_id).username + + +class TestAnonymousAccess: + @pytest.mark.parametrize('route', PROTECTED_ROUTES) + def test_anonymous_is_sent_to_login(self, client, route): + response = client.get(route, follow_redirects=False) + assert _redirected(response), f'{route} answered an anonymous caller' + assert '/auth/login' in response.headers.get('Location', '') + + +class TestVerticalAccess: + @pytest.mark.parametrize('route', ADMIN_ONLY_ROUTES) + @pytest.mark.parametrize('role', ['player', 'coach', 'manager', 'scout']) + def test_only_admin_reaches_user_management(self, client, as_role, role, route): + as_role(role) + response = client.get(route, follow_redirects=False) + assert _redirected(response), ( + f'{role} reached {route}, which is meant to be admin-only' + ) + + def test_admin_reaches_user_management(self, client, as_role): + as_role('admin') + assert client.get('/users').status_code == 200 + + def test_player_cannot_list_evaluations(self, client, as_role): + as_role('player') + assert _redirected(client.get('/evaluations', follow_redirects=False)) + + def test_scout_cannot_list_teams(self, client, as_role): + as_role('scout') + assert _redirected(client.get('/teams', follow_redirects=False)) + + def test_non_player_cannot_request_one_on_one(self, client, as_role): + as_role('coach') + assert _redirected(client.get('/users/one-on-one', follow_redirects=False)) + + def test_non_coach_cannot_reach_notes_dashboard(self, client, as_role): + as_role('manager') + assert _redirected(client.get('/users/notes-dashboard', follow_redirects=False)) + + def test_player_cannot_delete_another_user(self, app, client, as_role, make_user): + victim_id = make_user('player') + as_role('player') + + response = client.post(f'/users/{victim_id}/delete', follow_redirects=False) + assert _redirected(response) + + with app.app_context(): + assert db.session.get(User, victim_id) is not None, 'the user was deleted' + + +class TestHorizontalAccess: + def test_player_cannot_delete_another_players_availability( + self, app, client, as_role, make_user + ): + from app.models import PlayerDisponibility + from datetime import time + + owner_id = make_user('player') + with app.app_context(): + slot = PlayerDisponibility( + player_id=owner_id, day_of_week=1, + start_time=time(10, 0), end_time=time(10, 30), + ) + db.session.add(slot) + db.session.commit() + slot_id = slot.id + + as_role('player') + response = client.post(f'/users/disponibilities/{slot_id}/delete') + + assert response.status_code == 403 + with app.app_context(): + assert db.session.get(PlayerDisponibility, slot_id) is not None + + +class TestNestedResourceOwnership: + """SEC-AUTHZ-002 — routes taking both a parent and a child id have to + check that the child actually belongs to the parent. Authorising only + the parent lets a manager of tryout A reach a team of tryout B.""" + + @staticmethod + def _make_tryout_with_team(app, owner_id, title): + from datetime import date + + from app.models import Team, Tryout + + with app.app_context(): + tryout = Tryout( + title=title, game='Valorant', date=date(2030, 1, 1), + created_by=owner_id, status='upcoming', + ) + db.session.add(tryout) + db.session.flush() + team = Team(tryout_id=tryout.id, name=f'{title} squad', created_by=owner_id) + db.session.add(team) + db.session.commit() + return tryout.id, team.id + + def test_cannot_add_a_player_to_a_team_of_another_tryout( + self, app, client, as_role, make_user + ): + from app.models import TeamMember, TryoutRegistration + + other_admin = make_user('admin') + _, foreign_team_id = self._make_tryout_with_team(app, other_admin, 'Foreign') + + manager_id = as_role('manager') + own_tryout_id, _ = self._make_tryout_with_team(app, manager_id, 'Mine') + + player_id = make_user('player') + with app.app_context(): + db.session.add(TryoutRegistration( + tryout_id=own_tryout_id, player_id=player_id)) + db.session.commit() + + response = client.post( + f'/tryouts/{own_tryout_id}/team/{foreign_team_id}/add', + data={'player_id': player_id}, + follow_redirects=False, + ) + + assert response.status_code == 404, ( + 'a team belonging to another tryout was accepted' + ) + with app.app_context(): + assert TeamMember.query.filter_by(team_id=foreign_team_id).count() == 0 + + def test_cannot_add_a_player_who_is_not_registered( + self, app, client, as_role, make_user + ): + from app.models import TeamMember + + manager_id = as_role('manager') + tryout_id, team_id = self._make_tryout_with_team(app, manager_id, 'Mine') + outsider_id = make_user('player') + + client.post( + f'/tryouts/{tryout_id}/team/{team_id}/add', + data={'player_id': outsider_id}, + follow_redirects=True, + ) + + with app.app_context(): + assert TeamMember.query.filter_by(team_id=team_id).count() == 0 + + def test_a_registered_player_can_still_be_added( + self, app, client, as_role, make_user + ): + """Guard against over-correcting: the normal path must keep working.""" + from app.models import TeamMember, TryoutRegistration + + manager_id = as_role('manager') + tryout_id, team_id = self._make_tryout_with_team(app, manager_id, 'Mine') + player_id = make_user('player') + + with app.app_context(): + db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id)) + db.session.commit() + + client.post( + f'/tryouts/{tryout_id}/team/{team_id}/add', + data={'player_id': player_id, 'position': 'Duelist'}, + follow_redirects=True, + ) + + with app.app_context(): + member = TeamMember.query.filter_by(team_id=team_id).one() + assert member.player_id == player_id + assert member.position == 'Duelist' + + def test_a_non_numeric_player_id_does_not_crash(self, app, client, as_role): + """int(player_id) on raw form input used to raise, i.e. a 500.""" + manager_id = as_role('manager') + tryout_id, team_id = self._make_tryout_with_team(app, manager_id, 'Mine') + + response = client.post( + f'/tryouts/{tryout_id}/team/{team_id}/add', + data={'player_id': 'not-a-number'}, + follow_redirects=False, + ) + assert response.status_code < 500 + + +class TestInputValidation: + """SEC-AUTHZ-001 — regression guard. + + CreateUserSchema, EditUserSchema and EditProfileSchema used to be + imported at users.py:23-26 and never called: each name appeared exactly + once in the file, on its import line. The three routes read request.form + directly, so no password policy and no username format rule applied + anywhere in user management. These tests fail if that ever comes back. + """ + + def test_edit_profile_rejects_html_in_username(self, app, client, as_role): + user_id = as_role('player') + payload = '' + + client.post('/users/profile/edit', data={ + 'username': payload, + 'full_name': 'Legit Name', + 'email': 'legit@example.test', + }, follow_redirects=True) + + with app.app_context(): + assert db.session.get(User, user_id).username != payload + + def test_edit_profile_enforces_the_password_policy(self, app, client, as_role): + user_id = as_role('player') + with app.app_context(): + before = db.session.get(User, user_id).password_hash + + client.post('/users/profile/edit', data={ + 'username': _username(app, user_id), + 'full_name': 'Legit Name', + 'email': 'legit@example.test', + 'password': 'a', + }, follow_redirects=True) + + with app.app_context(): + assert db.session.get(User, user_id).password_hash == before, ( + 'a one-character password was accepted' + ) + + def test_create_user_enforces_the_password_policy(self, app, client, as_role): + as_role('admin') + + client.post('/users/create', data={ + 'username': 'weakling', + 'email': 'weak@example.test', + 'password': 'a', + 'full_name': 'Weak Account', + 'role': 'admin', + }, follow_redirects=True) + + with app.app_context(): + created = User.query.filter_by(username='weakling').first() + assert created is None, 'an admin account was created with password "a"' + + def test_edit_user_rejects_a_duplicate_email(self, app, client, as_role, make_user): + other_id = make_user('player') + target_id = make_user('player') + as_role('admin') + + with app.app_context(): + taken = db.session.get(User, other_id).email + + response = client.post(f'/users/{target_id}/edit', data={ + 'full_name': 'Target', + 'email': taken, + 'role': 'player', + }, follow_redirects=False) + + assert response.status_code < 500, 'duplicate email produced a server error' + + +class TestAdminSafety: + @pytest.mark.xfail( + strict=True, + reason='SEC-AUTHZ-007: the role change excludes neither the current ' + 'user nor the last remaining admin', + ) + def test_the_last_admin_cannot_demote_itself(self, app, client, as_role): + admin_id = as_role('admin') + + client.post(f'/users/{admin_id}/edit', data={ + 'full_name': 'Admin', + 'email': 'admin-self@example.test', + 'role': 'player', + }, follow_redirects=True) + + with app.app_context(): + assert db.session.get(User, admin_id).role == 'admin', ( + 'the only administrator demoted itself; no interface can undo this' + ) + + +class TestCsrf: + def test_state_changing_post_without_a_token_is_rejected(self, app_with_csrf): + """CSRFProtect is global. This pins that down so a future + @csrf.exempt cannot slip in unnoticed.""" + client = app_with_csrf.test_client() + response = client.post('/auth/login', data={ + 'username': 'someone', 'password': 'Password123', + }) + assert response.status_code == 400 diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py new file mode 100644 index 0000000..477a1a1 --- /dev/null +++ b/tests/test_security_headers.py @@ -0,0 +1,82 @@ +"""HTTP hardening, error disclosure, and template escaping.""" + +import pytest + +from app.app import nl2br + + +class TestSecurityHeaders: + def test_core_headers_are_present(self, client): + headers = client.get('/auth/login').headers + + assert headers['X-Content-Type-Options'] == 'nosniff' + assert headers['X-Frame-Options'] == 'DENY' + assert headers['Referrer-Policy'] == 'strict-origin-when-cross-origin' + assert 'frame-ancestors' in headers['Content-Security-Policy'] + + def test_deprecated_xss_auditor_header_is_not_sent(self, client): + """X-XSS-Protection was removed: deprecated, and harmful in its + last implementations.""" + assert 'X-XSS-Protection' not in client.get('/auth/login').headers + + @pytest.mark.xfail( + strict=True, + reason="SEC-WEB-001: 15 inline \nsecond')) + + assert ' - {% endblock %} \ No newline at end of file diff --git a/app/templates/pages/contracts.html b/app/templates/pages/contracts.html index 088607e..befddcf 100644 --- a/app/templates/pages/contracts.html +++ b/app/templates/pages/contracts.html @@ -55,7 +55,7 @@ {% endif %} {% if current_user.role == 'player' and contract.status == 'pending' %} - {% endif %} @@ -83,11 +83,11 @@ {% if current_user.role == 'player' %} @@ -65,7 +65,7 @@ {% else %}
- + Connect Discord Account Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc. @@ -165,8 +165,62 @@ function toggleGamertagInput(checkbox) { } } -// On page load, ensure gamertag groups match checkbox state +// Save form draft to sessionStorage before navigating to Discord OAuth +function saveFormDraft() { + var draft = {}; + var form = document.querySelector('.auth-form'); + if (!form) return; + var inputs = form.querySelectorAll('input, select, textarea'); + inputs.forEach(function(input) { + if (!input.name) return; + if (input.type === 'checkbox') { + if (!draft[input.name]) draft[input.name] = []; + if (input.checked) draft[input.name].push(input.value); + } else if (input.type === 'password') { + // Never save passwords + } else { + draft[input.name] = input.value; + } + }); + sessionStorage.setItem('register_form_draft', JSON.stringify(draft)); +} + +// Restore form draft from sessionStorage on page load +function restoreFormDraft() { + var saved = sessionStorage.getItem('register_form_draft'); + if (!saved) return; + try { + var draft = JSON.parse(saved); + var hasServerData = document.querySelector('.auth-form input[name="full_name"]').value !== ''; + if (hasServerData) return; + for (var key in draft) { + if (key === 'games') { + var values = draft[key]; + var checkboxes = document.querySelectorAll('input[name="games"]'); + checkboxes.forEach(function(cb) { + cb.checked = values.indexOf(cb.value) !== -1; + toggleGamertagInput(cb); + }); + } else if (key === 'csrf_token' || key === 'captcha_answer' || key.indexOf('password') !== -1) { + // Skip CSRF token, CAPTCHA, and passwords + } else { + var input = document.querySelector('input[name="' + key + '"], textarea[name="' + key + '"]'); + if (input) input.value = draft[key]; + } + } + } catch(e) { + // Invalid JSON, ignore + } +} + +// Clear draft on successful form submission +document.querySelector('.auth-form').addEventListener('submit', function() { + sessionStorage.removeItem('register_form_draft'); +}); + +// On page load, ensure gamertag groups match checkbox state and restore draft document.addEventListener('DOMContentLoaded', function() { + restoreFormDraft(); var checkboxes = document.querySelectorAll('input[name="games"]'); checkboxes.forEach(function(checkbox) { toggleGamertagInput(checkbox); From 47ff54484896e9d0cf0ec023bae739c7caaf25b6 Mon Sep 17 00:00:00 2001 From: GGThed Date: Tue, 11 Aug 2026 11:40:41 -0400 Subject: [PATCH 42/75] chore(lint): trier les imports, sauf la facade des modeles Active la regle isort (I) de ruff. 45 fichiers reordonnes, aucun changement de comportement : la suite passe avant comme apres. app/models/__init__.py en est exclu. Ses imports sont ranges en onze couches commentees qui decrivent le graphe de dependances ; trier par ordre alphabetique laisse chaque titre au-dessus d un import qu il ne decrit pas, et ce fichier n a qu un role, etre lu. Commit isole, comme le formatage : un diff de brassage ne doit pas servir de couverture a un changement de comportement. --- app/app.py | 17 ++++++----- app/discord_bot.py | 24 ++++++++------- app/extensions.py | 10 +++---- app/logging_config.py | 2 +- app/models/_associations.py | 1 - app/models/availability/__init__.py | 2 +- app/models/availability/base.py | 3 +- app/models/contract.py | 5 ++-- app/models/evaluation.py | 3 +- app/models/match_model/base.py | 3 +- app/models/one_on_one_request.py | 3 +- app/models/org_team/org_team.py | 3 +- app/models/org_team/team_player.py | 3 +- app/models/participant/base.py | 3 +- app/models/personal_note.py | 3 +- app/models/team/team.py | 3 +- app/models/team/team_member.py | 3 +- app/models/team_note.py | 3 +- app/models/tryout/tryout.py | 3 +- app/models/tryout/tryout_registration.py | 3 +- app/models/user_gamertag.py | 5 ++-- app/models/user_model/__init__.py | 4 +-- app/models/user_model/manager.py | 3 +- app/models/user_model/player.py | 2 +- app/models/user_model/user.py | 6 ++-- app/permissions.py | 1 - app/routes/auth.py | 22 +++++++------- app/routes/evaluations.py | 25 ++++++++-------- app/routes/main.py | 24 ++++++++------- app/routes/matches.py | 29 +++++++++--------- app/routes/team_matches.py | 12 ++++---- app/routes/teams.py | 26 ++++++++-------- app/routes/tryouts.py | 38 +++++++++++++----------- app/routes/users/__init__.py | 17 ++++++----- app/routes/users/_shared.py | 2 +- app/routes/users/accounts.py | 6 ++-- app/routes/users/availability.py | 1 - app/routes/users/one_on_one.py | 3 +- app/routes/users/profile.py | 6 ++-- app/supporting_scripts/run_https.py | 4 ++- app/supporting_scripts/security_scan.py | 8 ++--- app/validators.py | 15 +++++----- pyproject.toml | 18 +++++++---- tests/test_authorization.py | 3 +- wsgi.py | 3 +- 45 files changed, 213 insertions(+), 170 deletions(-) diff --git a/app/app.py b/app/app.py index 3c66a05..709e48f 100644 --- a/app/app.py +++ b/app/app.py @@ -7,14 +7,15 @@ the Flask application instance with comprehensive security hardening. import os import secrets -from flask import Flask, g, request, redirect, jsonify, render_template, url_for -from flask_cors import CORS -from app.extensions import db, login_manager, csrf, limiter, babel -from sqlalchemy import text -from werkzeug.exceptions import HTTPException import markupsafe from dotenv import load_dotenv +from flask import Flask, g, jsonify, redirect, render_template, request, url_for +from flask_cors import CORS +from sqlalchemy import text +from werkzeug.exceptions import HTTPException + from app import i18n +from app.extensions import babel, csrf, db, limiter, login_manager load_dotenv() @@ -265,13 +266,13 @@ def create_app(config=None): configure_logging(app) from app.routes.auth import auth_bp - from app.routes.tryouts import tryouts_bp from app.routes.evaluations import evaluations_bp - from app.routes.users import users_bp from app.routes.main import main_bp - from app.routes.teams import teams_bp from app.routes.matches import matches_bp from app.routes.team_matches import team_matches_bp + from app.routes.teams import teams_bp + from app.routes.tryouts import tryouts_bp + from app.routes.users import users_bp app.register_blueprint(auth_bp) app.register_blueprint(tryouts_bp) diff --git a/app/discord_bot.py b/app/discord_bot.py index 3e10e6a..12604a0 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -6,21 +6,22 @@ This module provides a persistent bot that handles: - Daily reminders at 18:00 EDT for upcoming events """ -import os +import asyncio import json import logging +import os import tempfile -import time -import asyncio import threading +import time import traceback from datetime import datetime, timedelta +from queue import Empty, Queue from zoneinfo import ZoneInfo -from queue import Queue, Empty -from discord import Intents -from discord.ext import commands + from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger +from discord import Intents +from discord.ext import commands from dotenv import load_dotenv load_dotenv() @@ -453,8 +454,8 @@ class TeamTryoutsBot(commands.Bot): async def handle_one_on_one_approve(self, coach, message_id, request_id, channel): """Handle coach approving a One on One request.""" try: - from app.models import OneOnOneRequest from app.extensions import db + from app.models import OneOnOneRequest request = OneOnOneRequest.query.get(request_id) if not request: @@ -498,8 +499,8 @@ class TeamTryoutsBot(commands.Bot): async def handle_one_on_one_reject(self, coach, message_id, request_id, channel): """Handle coach rejecting a One on One request.""" try: - from app.models import OneOnOneRequest from app.extensions import db + from app.models import OneOnOneRequest request = OneOnOneRequest.query.get(request_id) if not request: @@ -743,14 +744,15 @@ class TeamTryoutsBot(commands.Bot): async def _send_daily_reminders_impl(self): """Internal implementation of daily reminders with proper app context.""" try: + from sqlalchemy.orm import joinedload + from app.models import ( Match, - Tryout, MatchParticipant, - TryoutRegistration, OneOnOneRequest, + Tryout, + TryoutRegistration, ) - from sqlalchemy.orm import joinedload now = datetime.now(self.timezone) tomorrow = now.date() + timedelta(days=1) diff --git a/app/extensions.py b/app/extensions.py index d262e4b..c6ad0e1 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -1,10 +1,10 @@ -from flask_sqlalchemy import SQLAlchemy -from flask_login import LoginManager -from flask_wtf.csrf import CSRFProtect -from werkzeug.security import generate_password_hash, check_password_hash +from flask_babel import Babel from flask_limiter import Limiter from flask_limiter.util import get_remote_address -from flask_babel import Babel +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy +from flask_wtf.csrf import CSRFProtect +from werkzeug.security import check_password_hash, generate_password_hash # Database and extension initialization db = SQLAlchemy() diff --git a/app/logging_config.py b/app/logging_config.py index 003365c..2dd0616 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -11,8 +11,8 @@ Usage: import logging import os -from logging.handlers import RotatingFileHandler import re +from logging.handlers import RotatingFileHandler class SensitiveDataFilter(logging.Filter): diff --git a/app/models/_associations.py b/app/models/_associations.py index 207fa54..d41cc8b 100644 --- a/app/models/_associations.py +++ b/app/models/_associations.py @@ -2,7 +2,6 @@ from app.extensions import db - org_team_coaches = db.Table( 'org_team_coaches', db.Column( diff --git a/app/models/availability/__init__.py b/app/models/availability/__init__.py index b60623d..ae053f8 100644 --- a/app/models/availability/__init__.py +++ b/app/models/availability/__init__.py @@ -1,7 +1,7 @@ """Availability models — BaseAvailability and its concrete subclasses.""" from app.models.availability.base import BaseAvailability -from app.models.availability.player_disponibility import PlayerDisponibility from app.models.availability.coach_availability import CoachAvailability +from app.models.availability.player_disponibility import PlayerDisponibility __all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability'] diff --git a/app/models/availability/base.py b/app/models/availability/base.py index 6fc6c4e..4aee7ce 100644 --- a/app/models/availability/base.py +++ b/app/models/availability/base.py @@ -1,8 +1,9 @@ """Abstract base class for availability models (PlayerDisponibility + CoachAvailability).""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class BaseAvailability(db.Model): """Shared schema for player disponibilities and coach availabilities.""" diff --git a/app/models/contract.py b/app/models/contract.py index e1a97ca..889a9ac 100644 --- a/app/models/contract.py +++ b/app/models/contract.py @@ -1,8 +1,9 @@ """Contract documents for players to sign.""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class Contract(db.Model): """Contract documents for players to sign.""" @@ -48,8 +49,8 @@ class Contract(db.Model): return True from app.models.user_model.admin import Admin - from app.models.user_model.manager import Manager from app.models.user_model.coach import Coach + from app.models.user_model.manager import Manager from app.models.user_model.user import User from app.permissions import coach_can_access_player diff --git a/app/models/evaluation.py b/app/models/evaluation.py index 8127d6e..0492eb9 100644 --- a/app/models/evaluation.py +++ b/app/models/evaluation.py @@ -1,8 +1,9 @@ """Player evaluation record.""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class Evaluation(db.Model): """Player evaluation record.""" diff --git a/app/models/match_model/base.py b/app/models/match_model/base.py index ca63991..f95232c 100644 --- a/app/models/match_model/base.py +++ b/app/models/match_model/base.py @@ -1,8 +1,9 @@ """Abstract base class for match models (Match + TeamMatch).""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class BaseMatch(db.Model): """Shared schema for tryout-scoped matches and regular-season team matches.""" diff --git a/app/models/one_on_one_request.py b/app/models/one_on_one_request.py index 2a726ca..1b1e94f 100644 --- a/app/models/one_on_one_request.py +++ b/app/models/one_on_one_request.py @@ -1,8 +1,9 @@ """Request from player to coach for a One on One session.""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class OneOnOneRequest(db.Model): """Request from player to coach for a One on One session.""" diff --git a/app/models/org_team/org_team.py b/app/models/org_team/org_team.py index 86e9007..ae0a0b1 100644 --- a/app/models/org_team/org_team.py +++ b/app/models/org_team/org_team.py @@ -1,8 +1,9 @@ """Persistent organisation team (e.g. Varsity, JV).""" +from datetime import datetime + from app.extensions import db from app.models._associations import org_team_coaches, org_team_managers -from datetime import datetime class OrgTeam(db.Model): diff --git a/app/models/org_team/team_player.py b/app/models/org_team/team_player.py index c36fe7b..d07865c 100644 --- a/app/models/org_team/team_player.py +++ b/app/models/org_team/team_player.py @@ -1,8 +1,9 @@ """Many-to-many junction: player to org-team.""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class TeamPlayer(db.Model): """Many-to-many: player to org-team.""" diff --git a/app/models/participant/base.py b/app/models/participant/base.py index 6d5d99c..7293699 100644 --- a/app/models/participant/base.py +++ b/app/models/participant/base.py @@ -1,8 +1,9 @@ """Abstract base class for match participant models.""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class BaseParticipant(db.Model): """Shared schema for match participants.""" diff --git a/app/models/personal_note.py b/app/models/personal_note.py index b8c3bc8..4745202 100644 --- a/app/models/personal_note.py +++ b/app/models/personal_note.py @@ -1,8 +1,9 @@ """Personal notes from coach to individual player.""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class PersonalNote(db.Model): """Personal notes from coach to individual player.""" diff --git a/app/models/team/team.py b/app/models/team/team.py index d243fdc..4611fe1 100644 --- a/app/models/team/team.py +++ b/app/models/team/team.py @@ -1,8 +1,9 @@ """Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class Team(db.Model): """Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" diff --git a/app/models/team/team_member.py b/app/models/team/team_member.py index 8861298..391048f 100644 --- a/app/models/team/team_member.py +++ b/app/models/team/team_member.py @@ -1,8 +1,9 @@ """Link between a player and a tryout-specific team.""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class TeamMember(db.Model): """Link between a player and a tryout-specific team.""" diff --git a/app/models/team_note.py b/app/models/team_note.py index 46ebbfb..76c8c21 100644 --- a/app/models/team_note.py +++ b/app/models/team_note.py @@ -1,8 +1,9 @@ """Team improvement notes from coach.""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class TeamNote(db.Model): """Team improvement notes from coach.""" diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py index 3d34a49..e9f4a74 100644 --- a/app/models/tryout/tryout.py +++ b/app/models/tryout/tryout.py @@ -1,8 +1,9 @@ """Tryout event for player evaluations and team formation.""" +from datetime import datetime + from app.extensions import db from app.models._associations import tryout_coaches -from datetime import datetime class Tryout(db.Model): diff --git a/app/models/tryout/tryout_registration.py b/app/models/tryout/tryout_registration.py index 162f7d8..007e77b 100644 --- a/app/models/tryout/tryout_registration.py +++ b/app/models/tryout/tryout_registration.py @@ -1,8 +1,9 @@ """Registration linking a player to a tryout.""" -from app.extensions import db from datetime import datetime +from app.extensions import db + class TryoutRegistration(db.Model): """Registration linking a player to a tryout.""" diff --git a/app/models/user_gamertag.py b/app/models/user_gamertag.py index 8c2c591..589d32d 100644 --- a/app/models/user_gamertag.py +++ b/app/models/user_gamertag.py @@ -1,9 +1,10 @@ """Store gamertag per game for each user.""" -from app.extensions import db -from app.models._constants import TRN_URLS, PLATFORM_CODES, PLATFORM_DEFAULTS from urllib.parse import quote +from app.extensions import db +from app.models._constants import PLATFORM_CODES, PLATFORM_DEFAULTS, TRN_URLS + class UserGamertag(db.Model): """Store gamertag per game for each user.""" diff --git a/app/models/user_model/__init__.py b/app/models/user_model/__init__.py index 2a81dbb..a1e2670 100644 --- a/app/models/user_model/__init__.py +++ b/app/models/user_model/__init__.py @@ -1,10 +1,10 @@ """User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout).""" -from app.models.user_model.user import User from app.models.user_model.admin import Admin -from app.models.user_model.manager import Manager from app.models.user_model.coach import Coach +from app.models.user_model.manager import Manager from app.models.user_model.player import Player from app.models.user_model.scout import Scout +from app.models.user_model.user import User __all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout'] diff --git a/app/models/user_model/manager.py b/app/models/user_model/manager.py index 10a7b94..aa95fb3 100644 --- a/app/models/user_model/manager.py +++ b/app/models/user_model/manager.py @@ -27,9 +27,10 @@ class Manager(User): return True def get_visible_tryouts(self): - from app.models.tryout.tryout import Tryout from sqlalchemy import or_ + from app.models.tryout.tryout import Tryout + return ( Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id)) .order_by(Tryout.date) diff --git a/app/models/user_model/player.py b/app/models/user_model/player.py index 30229c0..a3ceeea 100644 --- a/app/models/user_model/player.py +++ b/app/models/user_model/player.py @@ -9,9 +9,9 @@ class Player(User): __mapper_args__ = {'polymorphic_identity': 'player'} def get_visible_tryouts(self): - from app.models.tryout.tryout import Tryout from app.models.match_model.match import Match from app.models.participant.match_participant import MatchParticipant + from app.models.tryout.tryout import Tryout # tryouts they registered for player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()] diff --git a/app/models/user_model/user.py b/app/models/user_model/user.py index 3a27544..231a726 100644 --- a/app/models/user_model/user.py +++ b/app/models/user_model/user.py @@ -1,9 +1,11 @@ """Base User model — shared fields and polymorphic configuration.""" -from app.extensions import db -from flask_login import UserMixin from datetime import datetime +from flask_login import UserMixin + +from app.extensions import db + class User(UserMixin, db.Model): """Base user model — shared fields for every role. diff --git a/app/permissions.py b/app/permissions.py index 00bfe7f..b0d39ae 100644 --- a/app/permissions.py +++ b/app/permissions.py @@ -28,7 +28,6 @@ and from tests without a request context. from app.extensions import db - # --------------------------------------------------------------------------- # Team attachment # --------------------------------------------------------------------------- diff --git a/app/routes/auth.py b/app/routes/auth.py index 0ad9015..ff0d383 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -5,21 +5,23 @@ protection, logout with session clearing, and new user registration with password policy enforcement and CAPTCHA verification. """ -import uuid import os import secrets +import uuid from datetime import datetime, timedelta -from flask import Blueprint, render_template, redirect, url_for, flash, request, session -from flask_login import login_user, logout_user, login_required, current_user -from app.extensions import db, hash_password, check_password, limiter -from app.models import User, Player, ESPORT_GAMES -from app.validators import RegisterSchema, LoginSchema +from urllib.parse import urlencode, urlparse + +import requests +from flask import Blueprint, flash, redirect, render_template, request, session, url_for +from flask_babel import gettext as _ +from flask_login import current_user, login_required, login_user, logout_user +from marshmallow import ValidationError + +from app.extensions import check_password, db, hash_password, limiter from app.i18n import LOCALE_SESSION_KEY from app.logging_config import log_auth_event -from flask_babel import gettext as _ -from marshmallow import ValidationError -from urllib.parse import urlparse, urlencode -import requests +from app.models import ESPORT_GAMES, Player, User +from app.validators import LoginSchema, RegisterSchema #: Session key holding the pending OAuth2 anti-forgery token. DISCORD_STATE_KEY = 'discord_oauth_state' diff --git a/app/routes/evaluations.py b/app/routes/evaluations.py index ba696c8..2f57a4b 100644 --- a/app/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -3,22 +3,23 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from flask import Blueprint, render_template, redirect, url_for, flash, request -from flask_login import login_required, current_user +from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_babel import gettext as _ -from app.extensions import db -from app.models import ( - Admin, - Player, - User, - Tryout, - Evaluation, - TryoutRegistration, - GAME_POSITIONS, -) +from flask_login import current_user, login_required from sqlalchemy import func from sqlalchemy.orm import aliased +from app.extensions import db +from app.models import ( + GAME_POSITIONS, + Admin, + Evaluation, + Player, + Tryout, + TryoutRegistration, + User, +) + evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') diff --git a/app/routes/main.py b/app/routes/main.py index 8b85347..2691b3d 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -3,27 +3,29 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from flask import Blueprint, render_template, redirect, url_for, flash, request -from flask_login import login_required, current_user +from datetime import date + +from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_babel import gettext as _ +from flask_login import current_user, login_required +from sqlalchemy import func + from app.extensions import db from app.models import ( Admin, - Manager, Coach, - Player, - Scout, - User, - Tryout, Evaluation, - TryoutRegistration, - TeamMember, + Manager, Match, MatchParticipant, + Player, + Scout, + TeamMember, + Tryout, + TryoutRegistration, + User, ) from app.permissions import coach_tryout_ids -from sqlalchemy import func -from datetime import date main_bp = Blueprint('main', __name__) diff --git a/app/routes/matches.py b/app/routes/matches.py index 1488f5a..8101bc0 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -3,30 +3,31 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify -from flask_login import login_required, current_user +from datetime import datetime, timedelta + +from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask_babel import gettext as _ +from flask_login import current_user, login_required +from sqlalchemy.orm import joinedload + from app.extensions import db from app.models import ( Admin, - Manager, Coach, - Player, - Scout, - User, - Tryout, + Manager, Match, MatchParticipant, - Team, - TeamMember, - TryoutRegistration, - PlayerDisponibility, OneOnOneRequest, PersonalNote, + Player, + PlayerDisponibility, + Scout, + Team, + TeamMember, + Tryout, + TryoutRegistration, + User, ) -from datetime import datetime, timedelta - -from sqlalchemy.orm import joinedload from app.services.scheduling import notify_participants, zip_participants matches_bp = Blueprint('matches', __name__, url_prefix='/matches') diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index 584a473..1d28c0f 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -3,22 +3,24 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify -from flask_login import login_required, current_user +from datetime import datetime, timedelta + +from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask_babel import gettext as _ +from flask_login import current_user, login_required + from app.extensions import db from app.models import ( Admin, - Manager, Coach, - Player, + Manager, OrgTeam, + Player, TeamMatch, TeamMatchParticipant, TeamPlayer, ) from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams -from datetime import datetime, timedelta from app.services.scheduling import notify_participants, zip_participants team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches') diff --git a/app/routes/teams.py b/app/routes/teams.py index d038437..62011f5 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -3,27 +3,29 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify -from flask_login import login_required, current_user +from datetime import datetime + +from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask_babel import gettext as _ +from flask_login import current_user, login_required + from app.extensions import db from app.models import ( Admin, - Manager, Coach, - Player, - OrgTeam, - User, - PersonalNote, - TeamNote, - Tryout, - TeamPlayer, - TeamMatch, Contract, + Manager, OneOnOneRequest, + OrgTeam, + PersonalNote, + Player, + TeamMatch, + TeamNote, + TeamPlayer, + Tryout, + User, ) from app.permissions import visible_org_teams -from datetime import datetime teams_bp = Blueprint('teams', __name__, url_prefix='/teams') diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index 84b6e7b..d304bb2 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -4,30 +4,32 @@ This module handles CRUD operations for tryouts and player registrations. Uses polymorphic isinstance checks instead of role-string comparisons. """ -from flask import Blueprint, render_template, redirect, url_for, flash, request, abort -from flask_login import login_required, current_user +from datetime import datetime + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask_babel import gettext as _ +from flask_login import current_user, login_required + from app.extensions import db from app.models import ( - Admin, - Manager, - Coach, - Player, - Scout, - User, - Tryout, - TryoutRegistration, - Evaluation, - Team, - TeamMember, - OrgTeam, - Match, - MatchParticipant, - PersonalNote, ESPORT_GAMES, GAME_POSITIONS, + Admin, + Coach, + Evaluation, + Manager, + Match, + MatchParticipant, + OrgTeam, + PersonalNote, + Player, + Scout, + Team, + TeamMember, + Tryout, + TryoutRegistration, + User, ) -from datetime import datetime tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts') diff --git a/app/routes/users/__init__.py b/app/routes/users/__init__.py index 9af3bf6..7fb7bbd 100644 --- a/app/routes/users/__init__.py +++ b/app/routes/users/__init__.py @@ -9,16 +9,16 @@ keeps its single `from app.routes.users import users_bp`. The blueprint itself lives in blueprint.py to keep that import one-directional. """ -from app.routes.users.blueprint import users_bp - # Imported for their side effect: each module attaches its routes to # users_bp. Order does not matter; none of them import each other. -from app.routes.users import accounts # noqa: F401,E402 -from app.routes.users import availability # noqa: F401,E402 -from app.routes.users import contracts # noqa: F401,E402 -from app.routes.users import notes # noqa: F401,E402 -from app.routes.users import one_on_one # noqa: F401,E402 -from app.routes.users import profile # noqa: F401,E402 +from app.routes.users import ( + accounts, # noqa: F401,E402 + availability, # noqa: F401,E402 + contracts, # noqa: F401,E402 + notes, # noqa: F401,E402 + one_on_one, # noqa: F401,E402 + profile, # noqa: F401,E402 +) # Re-exported because tests and other modules reach for them by name. from app.routes.users._shared import ( # noqa: F401,E402 @@ -26,6 +26,7 @@ from app.routes.users._shared import ( # noqa: F401,E402 ALLOWED_SIGNED_EXTENSIONS, pdf_upload_error, ) +from app.routes.users.blueprint import users_bp __all__ = [ 'ALLOWED_CONTRACT_EXTENSIONS', diff --git a/app/routes/users/_shared.py b/app/routes/users/_shared.py index 7951ea1..72a851e 100644 --- a/app/routes/users/_shared.py +++ b/app/routes/users/_shared.py @@ -8,7 +8,7 @@ from flask import flash, request from flask_babel import gettext as _ from app.extensions import db -from app.models import Admin, Coach, Manager, Player, Scout, GAME_PLATFORMS, UserGamertag +from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag ALLOWED_CONTRACT_EXTENSIONS = {'pdf'} ALLOWED_SIGNED_EXTENSIONS = {'pdf'} diff --git a/app/routes/users/accounts.py b/app/routes/users/accounts.py index 5bb4caa..1d431f8 100644 --- a/app/routes/users/accounts.py +++ b/app/routes/users/accounts.py @@ -12,12 +12,13 @@ from marshmallow import ValidationError from app.extensions import db, hash_password from app.logging_config import log_auth_event from app.models import ( + ESPORT_GAMES, + GAME_PLATFORMS, + USER_TYPES, Admin, CoachAvailability, Contract, - ESPORT_GAMES, Evaluation, - GAME_PLATFORMS, Match, MatchParticipant, OneOnOneRequest, @@ -31,7 +32,6 @@ from app.models import ( TeamPlayer, Tryout, TryoutRegistration, - USER_TYPES, User, UserGamertag, ) diff --git a/app/routes/users/availability.py b/app/routes/users/availability.py index 9ec1aa4..de594a2 100644 --- a/app/routes/users/availability.py +++ b/app/routes/users/availability.py @@ -15,7 +15,6 @@ from app.extensions import db from app.models import Coach, CoachAvailability, PlayerDisponibility, User from app.routes.users.blueprint import users_bp - DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] diff --git a/app/routes/users/one_on_one.py b/app/routes/users/one_on_one.py index 1f76024..6ff2829 100644 --- a/app/routes/users/one_on_one.py +++ b/app/routes/users/one_on_one.py @@ -119,7 +119,8 @@ def one_on_one(): return redirect(url_for('users.one_on_one')) # Build list of upcoming dates that have coach availability - from datetime import date as date_cls, timedelta as td + from datetime import date as date_cls + from datetime import timedelta as td today = date_cls.today() available_days = {av['day_of_week'] for av in coach_availability} diff --git a/app/routes/users/profile.py b/app/routes/users/profile.py index 69c4f33..2898431 100644 --- a/app/routes/users/profile.py +++ b/app/routes/users/profile.py @@ -8,11 +8,11 @@ from marshmallow import ValidationError from app.extensions import db, hash_password from app.logging_config import log_auth_event from app.models import ( - CoachAvailability, - Coach, - Contract, ESPORT_GAMES, GAME_PLATFORMS, + Coach, + CoachAvailability, + Contract, Player, User, ) diff --git a/app/supporting_scripts/run_https.py b/app/supporting_scripts/run_https.py index 788ca91..4ea3e8e 100644 --- a/app/supporting_scripts/run_https.py +++ b/app/supporting_scripts/run_https.py @@ -13,10 +13,12 @@ Accept the self-signed certificate warning in your browser to proceed. import os import socket -import subprocess import ssl +import subprocess import sys + from waitress.server import create_server + from app.app import create_app CERT_FILE = 'certs/localhost.pem' diff --git a/app/supporting_scripts/security_scan.py b/app/supporting_scripts/security_scan.py index e783462..dc8afa9 100644 --- a/app/supporting_scripts/security_scan.py +++ b/app/supporting_scripts/security_scan.py @@ -12,12 +12,12 @@ Usage: python security_scan.py [--url http://localhost:5000] """ -import os -import sys import json -import subprocess -import urllib.request +import os import ssl +import subprocess +import sys +import urllib.request from datetime import datetime diff --git a/app/validators.py b/app/validators.py index a22c886..1c6e981 100644 --- a/app/validators.py +++ b/app/validators.py @@ -11,18 +11,19 @@ Usage: """ import re + from flask_babel import lazy_gettext as _l from marshmallow import ( - Schema, - fields, - validate, - ValidationError, - pre_load, - validates_schema, EXCLUDE, + Schema, + ValidationError, + fields, + pre_load, + validate, + validates_schema, ) -from app.models import USER_TYPES +from app.models import USER_TYPES # ============================================================================= # Custom Validators diff --git a/pyproject.toml b/pyproject.toml index a29f5f6..a954859 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,11 +42,10 @@ exclude = [".venv", "venv", "migrations", "docs"] # RET return-flow tidiness # SIM obvious simplifications # UP syntax available on the 3.12 this targets -# -# isort (I) is not enabled yet: it would reorder imports in 48 files, i.e. a -# second sweep of pure churn right after the formatting commit. Worth doing, -# worth doing on its own. -select = ["E4", "E7", "E9", "F", "B", "C4", "RET", "SIM", "UP"] +# I isort; enabled in its own commit, for the same reason the +# formatting got one — the diff is churn and must not hide +# behind a behavioural change +select = ["E4", "E7", "E9", "F", "B", "C4", "RET", "SIM", "UP", "I"] # Forcing a ternary reads worse than the if/else it replaces in the one place # it fires (evaluations.py, choosing a sort direction). @@ -56,7 +55,14 @@ ignore = ["SIM108"] # Intentional re-export facade: `from app.models import User, Tryout, ...` # is the documented entry point, and importing the modules is what # registers every model with SQLAlchemy. -"app/models/__init__.py" = ["F401"] +# +# I001 is off here too. The imports are grouped into eleven commented +# layers that spell out the dependency graph (constants → users → teams → +# tryouts → matches → participants). Alphabetising them leaves every +# heading above an import it does not describe, and the file's one job is +# to be read. The order is documentation, not a requirement: the suite +# passes either way. +"app/models/__init__.py" = ["F401", "I001"] "app/models/user_model/__init__.py" = ["F401"] "tests/conftest.py" = ["E402"] diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 1e16df4..a865114 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -98,9 +98,10 @@ class TestHorizontalAccess: def test_player_cannot_delete_another_players_availability( self, app, client, as_role, make_user ): - from app.models import PlayerDisponibility from datetime import time + from app.models import PlayerDisponibility + owner_id = make_user('player') with app.app_context(): slot = PlayerDisponibility( diff --git a/wsgi.py b/wsgi.py index d9d6dfe..791a5dd 100644 --- a/wsgi.py +++ b/wsgi.py @@ -12,8 +12,9 @@ Configuration via environment variables: WAITRESS_THREADS: Number of worker threads (default: CPU*2+1) """ -import os import multiprocessing +import os + from app.app import create_app app = create_app() From d0a9e75fe69693703730f98078e7b7bca005697e Mon Sep 17 00:00:00 2001 From: GGThed Date: Tue, 11 Aug 2026 11:56:48 -0400 Subject: [PATCH 43/75] perf: servir les statiques par nginx, et dire ce que le bot n a pas livre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PERF-006. Le bloc location /static/ etait commente : 59 Ko de CSS et de JS passaient par Waitress a chaque page. L activer tel quel aurait ete une regression : ces URL ne changent jamais, donc un cache de 30 jours sert une feuille de style vieille d un mois apres chaque deploiement, sans moyen de l invalider. url_for('static') estampille maintenant chaque URL du mtime du fichier ; c est ce qui rend le immutable vrai et pas seulement rapide. Deux pieges nginx consignes dans le fichier : un add_header dans un location annule tous les add_header herites du server (nosniff disparaissait du JavaScript), et un statique manquant doit renvoyer 404 plutot que retomber sur Flask, sinon un deploiement casse se cache derriere une page qui marche. PERF-005. Les objets utilisateur Discord sont mis en cache. A etre precis sur le gain : un envoi coute deux appels reseau, resoudre puis envoyer, et seul le premier est economise — un premier match a vingt joueurs fait toujours vingt resolutions. Ce qui est gagne l est entre notifications, la ou le bot ecrit aux memes personnes soir apres soir. Chaque message dit desormais ce qu il est devenu, avec le destinataire et la raison. Les trois echecs ne se ressemblent pas et ne se lisent plus pareil : une boite fermee est definitive et ne se retente pas, une erreur HTTP est passagere, un identifiant sans proprietaire est un compte a corriger. Le lot quotidien annonce son propre deficit. Piege trouve en ecrivant les tests : configure_logging met propagate=False sur le logger 'app', et le handler de caplog est sur la racine. Les assertions sur les journaux passaient seules et echouaient dans la suite complete, ou une application avait deja ete construite — elles lisaient un journal vide, pas un bot silencieux. 417 tests. --- app/app.py | 34 +++ app/discord_bot.py | 426 +++++++++++++++++++++------------ app/nginx.conf | 42 +++- docs/deployment.md | 25 +- tests/test_discord_delivery.py | 272 +++++++++++++++++++++ tests/test_static_caching.py | 104 ++++++++ 6 files changed, 741 insertions(+), 162 deletions(-) create mode 100644 tests/test_discord_delivery.py create mode 100644 tests/test_static_caching.py diff --git a/app/app.py b/app/app.py index 709e48f..72085d1 100644 --- a/app/app.py +++ b/app/app.py @@ -147,6 +147,11 @@ def create_app(config=None): """ app = Flask(__name__) + # Cache-busting stamps for static files, filled lazily by the url_defaults + # hook below. Per application instance, so the test suite does not carry + # one app's mtimes into the next. + _static_stamps = {} + # --- defaults from the environment ------------------------------------ app.config['SECRET_KEY'] = os.getenv('SECRET_KEY') app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') @@ -244,6 +249,35 @@ def create_app(config=None): # unconditionally so templates can carry nonce="" beforehand. g.csp_nonce = secrets.token_urlsafe(16) + @app.url_defaults + def version_static_urls(endpoint, values): + """Stamp every static URL with the file's modification time. + + Without this, nginx cannot be allowed to cache style.css and main.js: + their URLs never change, so a 30-day expiry means a 30-day-old stylesheet + with no way to invalidate it short of telling people to hard-refresh. + With it, a deployed file gets a new URL and the old entry simply stops + being asked for — which is what makes the `immutable` in nginx.conf + true rather than merely fast (PERF-006). + + The stamp is computed once per file per process. The process restarts + on deploy, which is exactly when a file can have changed. + """ + if endpoint != 'static' or 'filename' not in values: + return + filename = values['filename'] + stamp = _static_stamps.get(filename) + if stamp is None: + try: + stamp = str(int(os.stat(os.path.join(app.static_folder, filename)).st_mtime)) + except OSError: + # A missing file is the template's problem, not this hook's: + # let the URL build and let the 404 say so. + stamp = '' + _static_stamps[filename] = stamp + if stamp: + values['v'] = stamp + @app.context_processor def inject_csp_nonce(): return { diff --git a/app/discord_bot.py b/app/discord_bot.py index 12604a0..6c7a784 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -20,7 +20,7 @@ from zoneinfo import ZoneInfo from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger -from discord import Intents +from discord import Forbidden, HTTPException, Intents, NotFound from discord.ext import commands from dotenv import load_dotenv @@ -44,6 +44,23 @@ PENDING_MAX_AGE_DAYS = 30 #: restart or a slow start-up, short of the next day's occurrence. REMINDER_GRACE_SECONDS = 3600 +#: How many Discord user objects to keep resolved (PERF-005). +#: +#: A direct message costs two sequential API calls: resolve the snowflake to +#: a user, then send. The first is identical every time for the same person, +#: and the bot writes to the same few dozen people over and over — a season +#: of matches, then a reminder every evening at 18:00. +#: +#: What this does not fix: the first notification of a twenty-player match +#: still resolves twenty distinct users. The saving is across notifications, +#: not within one. Cutting the second call would need Discord's bulk DM +#: endpoints, which do not exist. +#: +#: Bounded because the process is long-lived. Eviction is oldest-first on the +#: insertion order of the dict, close enough to least-recently-used for a +#: roster that fits several times over. +USER_CACHE_MAX = 512 + CHECK_EMOJI = '✅' # Green checkmark CROSS_EMOJI = '❌' # Red X @@ -75,6 +92,7 @@ class TeamTryoutsBot(commands.Bot): self.message_queue = Queue() # Thread-safe queue for messages from Flask self.scheduler = AsyncIOScheduler() self.timezone = ZoneInfo('America/Toronto') # EDT timezone + self._user_cache = {} # Discord snowflake -> User, bounded (PERF-005) def _load_pending(self): """Load pending requests from the JSON file. @@ -217,6 +235,88 @@ class TeamTryoutsBot(commands.Bot): except Exception as e: logger.error(f'Error starting scheduler: {e}') + async def _resolve_user(self, discord_uid, *, context=''): + """Return the Discord user behind a snowflake, or None. + + Tries three sources in order of cost: the library's own cache, which + is free but mostly empty since the members intent was dropped + (OPS-014); ours, which survives across notifications; then the API. + + A failure here is logged once, saying which of the two reasons it + was — a snowflake nobody owns is a data problem to fix in the + account, an HTTP error is Discord being Discord (PERF-005). + """ + try: + uid = int(discord_uid) + except (TypeError, ValueError): + logger.warning('Invalid Discord user id %r%s', discord_uid, context) + return None + + user = self.get_user(uid) or self._user_cache.get(uid) + if user is not None: + return user + + try: + user = await self.fetch_user(uid) + except NotFound: + logger.warning( + 'Discord user %s does not exist%s. The id stored on the account is ' + 'wrong or the account was deleted; nothing will ever be delivered ' + 'to it.', + uid, + context, + ) + return None + except HTTPException as exc: + logger.warning('Could not resolve Discord user %s%s: %s', uid, context, exc) + return None + + if user is None: + return None + + self._user_cache[uid] = user + while len(self._user_cache) > USER_CACHE_MAX: + self._user_cache.pop(next(iter(self._user_cache))) + return user + + async def _send_dm(self, discord_uid, message, *, purpose, recipient=''): + """Send one direct message and record what became of it. + + Returns the sent Message, or None. Every path logs exactly once, with + the recipient and the purpose, so that nineteen deliveries out of + twenty read as nineteen successes and one named failure instead of + looking like twenty (PERF-005). + + The three failures are not the same problem and must not read alike: + `blocked` is permanent until the person reopens their DMs and no + retry will change it, `failed` is transient, `unreachable` means the + account itself could not be resolved. + """ + context = f' ({purpose}{", " + recipient if recipient else ""})' + user = await self._resolve_user(discord_uid, context=context) + if user is None: + logger.warning('Notification not delivered — %s: recipient unreachable', purpose) + return None + + who = recipient or getattr(user, 'name', str(discord_uid)) + try: + sent = await user.send(message) + except Forbidden: + logger.warning( + 'Notification not delivered — %s to %s: their direct messages are ' + 'closed to this bot. Retrying will not help; they have to allow ' + 'DMs from server members.', + purpose, + who, + ) + return None + except HTTPException as exc: + logger.error('Notification not delivered — %s to %s: %s', purpose, who, exc) + return None + + logger.info('Delivered %s to %s (message_id=%s)', purpose, who, sent.id) + return sent + async def process_queue(self): """Process messages from the queue (runs continuously).""" while True: @@ -266,11 +366,7 @@ class TeamTryoutsBot(commands.Bot): return # Fetch the user who reacted - try: - user = await self.fetch_user(payload.user_id) - except Exception: - return - + user = await self._resolve_user(payload.user_id, context=' (reaction handler)') if user is None: return @@ -336,47 +432,50 @@ class TeamTryoutsBot(commands.Bot): request_id: int, ) -> int: """Send a One on One request DM to a coach with reactions.""" - try: - user_id = int(coach_discord_id) - except (ValueError, TypeError): - logger.warning(f"Invalid coach_discord_id '{coach_discord_id}'") + message = ( + "📅 **One on One Request**\n\n" + f"**Player:** {player_name}\n" + f"**Team:** {team_name or 'Unknown Team'}\n" + f"**Date:** {date_str}\n" + f"**Time:** {start_time} - {end_time}\n" + f"**Discussion Points:** {points or 'No specific points provided'}\n\n" + "Please respond by clicking a reaction below:\n" + f"{CHECK_EMOJI} - Confirm the meeting\n" + f"{CROSS_EMOJI} - Decline (you can add a reason by replying before clicking)" + ) + + msg = await self._send_dm( + coach_discord_id, + message, + purpose='one-on-one request', + recipient=coach_name, + ) + if msg is None: return None try: - user = await self.fetch_user(user_id) - if not user: - return None - - message = ( - "📅 **One on One Request**\n\n" - f"**Player:** {player_name}\n" - f"**Team:** {team_name or 'Unknown Team'}\n" - f"**Date:** {date_str}\n" - f"**Time:** {start_time} - {end_time}\n" - f"**Discussion Points:** {points or 'No specific points provided'}\n\n" - "Please respond by clicking a reaction below:\n" - f"{CHECK_EMOJI} - Confirm the meeting\n" - f"{CROSS_EMOJI} - Decline (you can add a reason by replying before clicking)" - ) - - msg = await user.send(message) await msg.add_reaction(CHECK_EMOJI) await msg.add_reaction(CROSS_EMOJI) + except HTTPException as exc: + # The message is out; without reactions the coach cannot answer + # from Discord, but the web interface still works. + logger.error( + 'One on One request %s reached %s without its reactions (%s). It can ' + 'only be answered from the web interface.', + request_id, + coach_name, + exc, + ) + return None - # Track this pending request - self.pending_requests[msg.id] = { - 'type': 'one_on_one', - 'id': request_id, - 'created_at': time.time(), - } - self._save_pending() - - logger.info(f"Sent One on One DM with reactions, message_id={msg.id}") - return msg.id - - except Exception as e: - logger.error(f"Error sending One on One DM: {e}") - return None + # Track this pending request + self.pending_requests[msg.id] = { + 'type': 'one_on_one', + 'id': request_id, + 'created_at': time.time(), + } + self._save_pending() + return msg.id async def _send_schedule_notification( self, @@ -397,59 +496,63 @@ class TeamTryoutsBot(commands.Bot): event_time: Time string. reference_id: ID of the MatchParticipant or TryoutRegistration record. """ + # Look up the DB user to get their Discord user ID + from app.models import User as DBUser + + db_user = DBUser.query.get(user_id) + if not db_user: + logger.warning(f"DB user {user_id} not found for schedule notification") + return None + + if not db_user.discord_user_id: + logger.warning(f"User {db_user.username} has no Discord user ID, cannot send DM") + return None + + event_name = "Match" if event_type == 'match' else "Tryout" + + message = ( + f"📅 **{event_name} Scheduled**\n\n" + f"You have been added to the following {event_type}:\n" + f"**{event_title}**\n" + f"**Date:** {event_date}\n" + f"**Time:** {event_time}\n\n" + "Please confirm your attendance:\n" + f"{CHECK_EMOJI} - Confirm attendance\n" + f"{CROSS_EMOJI} - Decline" + ) + + msg = await self._send_dm( + db_user.discord_user_id, + message, + purpose=f'{event_type} schedule notification', + recipient=db_user.username, + ) + if msg is None: + return None + try: - # Look up the DB user to get their Discord user ID - from app.models import User as DBUser - - db_user = DBUser.query.get(user_id) - if not db_user: - logger.warning(f"DB user {user_id} not found for schedule notification") - return None - - if not db_user.discord_user_id: - logger.warning(f"User {db_user.username} has no Discord user ID, cannot send DM") - return None - - discord_uid = int(db_user.discord_user_id) - user = await self.fetch_user(discord_uid) - if not user: - logger.warning(f"Could not fetch Discord user {discord_uid}") - return None - - event_name = "Match" if event_type == 'match' else "Tryout" - - message = ( - f"📅 **{event_name} Scheduled**\n\n" - f"You have been added to the following {event_type}:\n" - f"**{event_title}**\n" - f"**Date:** {event_date}\n" - f"**Time:** {event_time}\n\n" - "Please confirm your attendance:\n" - f"{CHECK_EMOJI} - Confirm attendance\n" - f"{CROSS_EMOJI} - Decline" - ) - - msg = await user.send(message) await msg.add_reaction(CHECK_EMOJI) await msg.add_reaction(CROSS_EMOJI) - - # Track this pending request - self.pending_requests[msg.id] = { - 'type': 'schedule_addition', - 'id': reference_id, - 'event_type': event_type, - 'created_at': time.time(), - } - self._save_pending() - - logger.info( - f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}" + except HTTPException as exc: + # Without the reactions the player has no way to answer: the + # message asks them to click something that is not there. + logger.error( + 'Schedule notification reached %s without its reactions (%s). They ' + 'cannot confirm attendance from Discord.', + db_user.username, + exc, ) - return msg.id + return None - except Exception as e: - logger.error(f"Error sending schedule notification: {e}") - return None + # Track this pending request + self.pending_requests[msg.id] = { + 'type': 'schedule_addition', + 'id': reference_id, + 'event_type': event_type, + 'created_at': time.time(), + } + self._save_pending() + return msg.id async def handle_one_on_one_approve(self, coach, message_id, request_id, channel): """Handle coach approving a One on One request.""" @@ -693,11 +796,6 @@ class TeamTryoutsBot(commands.Bot): logger.warning(f"Player has no Discord user ID for request {request.id}") return - player_user = await self.fetch_user(int(player_discord_id)) - if not player_user: - logger.warning(f"Could not fetch Discord user {player_discord_id}") - return - if approved: message = ( "🎉 **One on One Session Confirmed!**\n\n" @@ -722,9 +820,11 @@ class TeamTryoutsBot(commands.Bot): "Please try selecting a different time slot." ) - await player_user.send(message) - logger.info( - f"Sent One on One notification to player {player_full_name} (request {request.id})" + await self._send_dm( + player_discord_id, + message, + purpose=f'one-on-one response (request {request.id})', + recipient=player_full_name, ) except Exception as e: @@ -757,13 +857,20 @@ class TeamTryoutsBot(commands.Bot): now = datetime.now(self.timezone) tomorrow = now.date() + timedelta(days=1) + # Counted so that a partial run is visible as such. Per-recipient + # failures are logged by _send_dm; without this total, a batch in + # which half the reminders bounced looked exactly like one where + # they all went out (PERF-005). + attempted = delivered = 0 + # Find matches for tomorrow matches = Match.query.filter(Match.date == tomorrow).all() for match in matches: participants = MatchParticipant.query.filter_by(match_id=match.id).all() for participant in participants: if participant.player.discord_user_id: - await self.send_match_reminder(participant.player, match) + attempted += 1 + delivered += await self.send_match_reminder(participant.player, match) # Find tryouts for tomorrow tryouts = Tryout.query.filter(Tryout.date == tomorrow).all() @@ -771,7 +878,8 @@ class TeamTryoutsBot(commands.Bot): registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all() for reg in registrations: if reg.player.discord_user_id: - await self.send_tryout_reminder(reg.player, tryout) + attempted += 1 + delivered += await self.send_tryout_reminder(reg.player, tryout) # Find One on One sessions for tomorrow (only approved ones) one_on_ones = ( @@ -783,42 +891,59 @@ class TeamTryoutsBot(commands.Bot): ) for session in one_on_ones: if session.player and session.player.discord_user_id: - await self.send_one_on_one_reminder(session.player, session) + attempted += 1 + delivered += await self.send_one_on_one_reminder(session.player, session) + + if attempted and delivered < attempted: + logger.warning( + 'Daily reminders for %s: %d of %d delivered, %d failed. See the ' + 'lines above for who and why.', + tomorrow, + delivered, + attempted, + attempted - delivered, + ) + else: + logger.info('Daily reminders for %s: %d delivered', tomorrow, delivered) except Exception as e: logger.error(f"Error sending daily reminders: {e}") async def send_match_reminder(self, player, match): - """Send match reminder to player.""" - try: - player_user = await self.fetch_user(int(player.discord_user_id)) - message = ( - "🔔 **Match Reminder**\n\n" - f"Your match **{match.title}** is scheduled for tomorrow:\n" - f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n" - f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - " - f"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n" - f"**Location:** {match.location or 'TBD'}\n\n" - "Please confirm your attendance in the app." - ) - await player_user.send(message) - except Exception as e: - logger.error(f"Error sending match reminder: {e}") + """Send match reminder to player. True if it was delivered.""" + message = ( + "🔔 **Match Reminder**\n\n" + f"Your match **{match.title}** is scheduled for tomorrow:\n" + f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n" + f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - " + f"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n" + f"**Location:** {match.location or 'TBD'}\n\n" + "Please confirm your attendance in the app." + ) + sent = await self._send_dm( + player.discord_user_id, + message, + purpose='match reminder', + recipient=player.username, + ) + return sent is not None async def send_tryout_reminder(self, player, tryout): - """Send tryout reminder to player.""" - try: - player_user = await self.fetch_user(int(player.discord_user_id)) - message = ( - "🔔 **Tryout Reminder**\n\n" - f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n" - f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n" - f"**Location:** {tryout.location or 'TBD'}\n\n" - "Please confirm your attendance in the app." - ) - await player_user.send(message) - except Exception as e: - logger.error(f"Error sending tryout reminder: {e}") + """Send tryout reminder to player. True if it was delivered.""" + message = ( + "🔔 **Tryout Reminder**\n\n" + f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n" + f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n" + f"**Location:** {tryout.location or 'TBD'}\n\n" + "Please confirm your attendance in the app." + ) + sent = await self._send_dm( + player.discord_user_id, + message, + purpose='tryout reminder', + recipient=player.username, + ) + return sent is not None async def _send_one_on_one_response_dm( self, @@ -841,11 +966,6 @@ class TeamTryoutsBot(commands.Bot): logger.warning("Cannot send response DM: no player_discord_id") return False - player_user = await self.fetch_user(int(player_discord_id)) - if not player_user: - logger.warning(f"Could not fetch Discord user {player_discord_id}") - return False - if approved: message = ( "🎉 **One on One Session Confirmed!**\n\n" @@ -870,31 +990,35 @@ class TeamTryoutsBot(commands.Bot): "Please try selecting a different time slot." ) - await player_user.send(message) - logger.info( - f"Sent One on One response DM to player {player_full_name} (approved={approved})" + sent = await self._send_dm( + player_discord_id, + message, + purpose=f'one-on-one response ({"approved" if approved else "declined"})', + recipient=player_full_name, ) - return True + return sent is not None except Exception as e: logger.error(f"Error sending One on One response DM: {e}") return False async def send_one_on_one_reminder(self, player, session): - """Send One on One reminder to player.""" - try: - player_user = await self.fetch_user(int(player.discord_user_id)) - message = ( - "🔔 **One on One Reminder**\n\n" - f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n" - f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n" - f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n" - f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n" - "Please prepare for your session!" - ) - await player_user.send(message) - except Exception as e: - logger.error(f"Error sending One on One reminder: {e}") + """Send One on One reminder to player. True if it was delivered.""" + message = ( + "🔔 **One on One Reminder**\n\n" + f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n" + f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n" + f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n" + f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n" + "Please prepare for your session!" + ) + sent = await self._send_dm( + player.discord_user_id, + message, + purpose='one-on-one reminder', + recipient=player.username, + ) + return sent is not None # Global bot instance diff --git a/app/nginx.conf b/app/nginx.conf index 71c10ff..bae6fa1 100644 --- a/app/nginx.conf +++ b/app/nginx.conf @@ -143,15 +143,41 @@ http { } # --------------------------------------------------------------------- - # Static Files (served directly by Nginx for performance) - # Uncomment and adjust path if you want Nginx to serve static files + # Static Files (PERF-006) + # + # 59 KB of CSS and JS on every page load, previously proxied through + # Waitress. Nginx serves them from disk instead. + # + # ADJUST THIS ONE PATH to the deployment's checkout, absolute, forward + # slashes even on Windows. Nginx resolves a relative path against its + # own install prefix, not against this file. The trailing slash on both + # the location and the alias is required: without it /static/css/x.css + # resolves one directory too high. + # + # `immutable` is safe here and only here: url_for('static', …) appends + # ?v= (see version_static_urls in app/app.py), so a deployed file + # is requested under a new URL and the cached copy of the old one is + # never asked for again. Removing that stamp and leaving this block + # gives every visitor a month-old stylesheet. # --------------------------------------------------------------------- - # location /static/ { - # alias C:/path/to/team-tryouts/static/; - # expires 30d; - # add_header Cache-Control "public, immutable"; - # access_log off; - # } + location /static/ { + alias C:/team-tryouts/app/static/; + expires 30d; + access_log off; + + # These three are repeated on purpose. In nginx, add_header is + # inherited from the enclosing block ONLY when the current block + # declares none of its own — one add_header here silently drops + # every security header set at server level. Dropping nosniff on + # the JavaScript is the one that matters. + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + + # A missing static file must 404, not fall through to Flask: the + # fallthrough would hide a broken deploy behind a working page. + try_files $uri =404; + } # --------------------------------------------------------------------- # Rate Limiting diff --git a/docs/deployment.md b/docs/deployment.md index baafc41..a1e6f6e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -64,11 +64,30 @@ BACKUP_RETENTION_DAYS=30 ## Step 3: Configure Nginx -1. Copy `nginx.conf` to your Nginx installation directory (e.g., `C:\nginx\conf\`) -2. Place SSL certificate files: +1. Copy `app/nginx.conf` to your Nginx installation directory (e.g., `C:\nginx\conf\`) +2. **Edit the `alias` in the `location /static/` block** to point at this + checkout's `app/static/` directory — absolute path, forward slashes, keep + the trailing slash. It ships as `C:/team-tryouts/app/static/`, which is a + guess about your machine. Nginx resolves a relative path against its own + install prefix, not against `nginx.conf`. +3. Place SSL certificate files: - `C:\nginx\certs\fullchain.pem` - `C:\nginx\certs\privkey.pem` -3. Start Nginx: `C:\nginx\nginx.exe` +4. Check the configuration parses before restarting: `C:\nginx\nginx.exe -t` +5. Start Nginx: `C:\nginx\nginx.exe` + +Nginx serves `/static/` from disk with a 30-day `immutable` cache. That is +only safe because `url_for('static', …)` appends `?v=` to every static +URL (`version_static_urls` in `app/app.py`), so a redeployed file is requested +under a new URL. If that stamp is ever removed, remove the cache headers with +it or visitors keep a month-old stylesheet. + +After a deploy, confirm the stamp changed rather than trusting it: + +```powershell +# The v= value must differ from the one served before the deploy. +(Invoke-WebRequest https://your-domain/auth/login).Content -match 'style\.css\?v=(\d+)' +``` ### Obtaining SSL Certificates diff --git a/tests/test_discord_delivery.py b/tests/test_discord_delivery.py new file mode 100644 index 0000000..f0fea05 --- /dev/null +++ b/tests/test_discord_delivery.py @@ -0,0 +1,272 @@ +"""Resolving Discord users, and saying what became of each message. + +PERF-005. A direct message costs two sequential API calls — resolve the +snowflake, then send — and the first one is identical every time for the +same person. The bot writes to the same roster over and over, so resolution +is cached across notifications. + +The other half of the constat was visibility: a batch of twenty reminders in +which three bounced produced no line saying three had bounced. Failures are +now named, per recipient, and the daily batch reports its own shortfall. + +Nothing here talks to Discord. The coroutines are driven with asyncio.run +rather than pytest-asyncio, which the project does not depend on. +""" + +import asyncio +import logging + +import pytest +from discord import Forbidden, HTTPException, NotFound + +from app.discord_bot import USER_CACHE_MAX, TeamTryoutsBot + + +class FakeUser: + """Stands in for a discord.User. Records what it was asked to send.""" + + def __init__(self, uid, name=None, raises=None): + self.id = uid + self.name = name or f'user{uid}' + self.raises = raises + self.sent = [] + + async def send(self, message): + if self.raises is not None: + raise self.raises + self.sent.append(message) + return FakeMessage(1000 + len(self.sent)) + + +class FakeMessage: + def __init__(self, mid): + self.id = mid + self.reactions = [] + + async def add_reaction(self, emoji): + self.reactions.append(emoji) + + +def _response(status): + """A minimal object with the attributes discord's exceptions read.""" + + class _R: + def __init__(self): + self.status = status + self.reason = 'test' + + return _R() + + +def forbidden(): + return Forbidden(_response(403), 'Cannot send messages to this user') + + +def http_error(): + return HTTPException(_response(500), 'Internal Server Error') + + +def not_found(): + return NotFound(_response(404), 'Unknown User') + + +@pytest.fixture +def logs(caplog): + """Let the bot's records reach caplog. + + configure_logging sets propagate = False on the 'app' logger so records + are not written twice, and caplog's handler sits on the root logger. So + these tests passed on their own and failed in the full suite, where some + earlier test had already built an application — the assertions were + reading an empty log, not a silent bot. + + Restored afterwards. The production setting is right; it is only in the + way here. + """ + package_logger = logging.getLogger('app') + previous = package_logger.propagate + package_logger.propagate = True + caplog.set_level(logging.INFO, logger='app.discord_bot') + yield caplog + package_logger.propagate = previous + + +@pytest.fixture +def bot(): + """A bot object with nothing but the state the delivery path needs. + + __new__ rather than the constructor: TeamTryoutsBot.__init__ builds a + real discord.py client, which wants an event loop and a token. + """ + instance = TeamTryoutsBot.__new__(TeamTryoutsBot) + instance._user_cache = {} + instance.pending_requests = {} + instance.fetch_calls = [] + + # The library's own cache. Empty by default: the members intent was + # dropped in OPS-014, so in production it almost always misses. + instance.library_cache = {} + instance.get_user = instance.library_cache.get + + async def fetch_user(uid): + instance.fetch_calls.append(uid) + if uid in instance.fetch_failures: + raise instance.fetch_failures[uid] + return FakeUser(uid) + + instance.fetch_failures = {} + instance.fetch_user = fetch_user + return instance + + +class TestResolution: + def test_the_same_recipient_is_fetched_once(self, bot): + first = asyncio.run(bot._resolve_user(42)) + second = asyncio.run(bot._resolve_user(42)) + + assert first is second + assert bot.fetch_calls == [42], 'the second lookup should have come from the cache' + + def test_distinct_recipients_are_each_fetched(self, bot): + """The cache pays off across notifications, not within one. + + Twenty players in one match are still twenty lookups. Claiming + otherwise in the commit message would have been the easy lie. + """ + for uid in range(10, 20): + asyncio.run(bot._resolve_user(uid)) + + assert bot.fetch_calls == list(range(10, 20)) + + def test_the_library_cache_is_consulted_before_the_api(self, bot): + known = FakeUser(7) + bot.library_cache[7] = known + + assert asyncio.run(bot._resolve_user(7)) is known + assert bot.fetch_calls == [] + + def test_a_string_snowflake_resolves(self, bot): + """Every caller reads discord_user_id off a model, where it is text.""" + asyncio.run(bot._resolve_user('42')) + asyncio.run(bot._resolve_user(42)) + + assert bot.fetch_calls == [42], 'the string and the int must be the same cache entry' + + def test_a_malformed_id_is_rejected_without_a_call(self, bot): + assert asyncio.run(bot._resolve_user('not-a-snowflake')) is None + assert asyncio.run(bot._resolve_user(None)) is None + assert bot.fetch_calls == [] + + def test_the_cache_is_bounded(self, bot): + for uid in range(USER_CACHE_MAX + 25): + asyncio.run(bot._resolve_user(uid)) + + assert len(bot._user_cache) <= USER_CACHE_MAX + + def test_an_unknown_account_is_not_cached(self, bot): + """A snowflake nobody owns must stay retryable. + + Caching the miss would mean that fixing the id on the account has no + effect until the process restarts. + """ + bot.fetch_failures[99] = not_found() + + assert asyncio.run(bot._resolve_user(99)) is None + assert asyncio.run(bot._resolve_user(99)) is None + assert bot.fetch_calls == [99, 99] + + def test_a_transport_failure_says_which_kind_it_was(self, bot, logs): + bot.fetch_failures[99] = not_found() + bot.fetch_failures[98] = http_error() + + with logs.at_level(logging.WARNING, logger='app.discord_bot'): + asyncio.run(bot._resolve_user(99)) + asyncio.run(bot._resolve_user(98)) + + text = logs.text + assert 'does not exist' in text, 'a bad id is an account to fix, and must read that way' + assert 'Could not resolve' in text + + +class TestDelivery: + def test_a_delivered_message_is_logged_with_its_recipient(self, bot, logs): + with logs.at_level(logging.INFO, logger='app.discord_bot'): + sent = asyncio.run(bot._send_dm(5, 'hello', purpose='match reminder', recipient='ana')) + + assert sent is not None + assert 'match reminder' in logs.text + assert 'ana' in logs.text + + def test_closed_dms_are_named_and_not_confused_with_an_outage(self, bot, logs): + bot.library_cache[5] = FakeUser(5, raises=forbidden()) + + with logs.at_level(logging.WARNING, logger='app.discord_bot'): + sent = asyncio.run(bot._send_dm(5, 'hello', purpose='match reminder', recipient='ana')) + + assert sent is None + assert 'ana' in logs.text + assert 'Retrying will not help' in logs.text, ( + 'a closed inbox is permanent; reporting it like a transient error ' + 'sends someone chasing an outage that is not there' + ) + + def test_a_transient_failure_is_an_error_not_a_warning(self, bot, logs): + bot.library_cache[5] = FakeUser(5, raises=http_error()) + + with logs.at_level(logging.WARNING, logger='app.discord_bot'): + sent = asyncio.run(bot._send_dm(5, 'x', purpose='match reminder', recipient='ana')) + + assert sent is None + levels = {record.levelno for record in logs.records} + assert logging.ERROR in levels + + def test_an_unreachable_recipient_still_produces_a_line(self, bot, logs): + bot.fetch_failures[5] = not_found() + + with logs.at_level(logging.WARNING, logger='app.discord_bot'): + sent = asyncio.run(bot._send_dm(5, 'x', purpose='tryout reminder', recipient='ana')) + + assert sent is None + assert 'not delivered' in logs.text + assert 'tryout reminder' in logs.text + + +class TestReminderOutcomes: + """The daily batch counts what it delivered, so it needs a real answer. + + These three return values feed `delivered += await …` in + _send_daily_reminders_impl. A reminder that returned None on both paths + would make a wholly failed batch report as a wholly successful one. + """ + + class FakePlayer: + username = 'ana' + discord_user_id = '5' + + class FakeMatch: + title = 'Finals' + location = 'Arena' + + from datetime import date, time + + date = date(2026, 8, 12) + start_time = time(18, 0) + end_time = time(20, 0) + + class FakeTryout: + title = 'Open tryout' + location = 'Arena' + + from datetime import date + + date = date(2026, 8, 12) + + def test_a_delivered_reminder_reports_true(self, bot): + assert asyncio.run(bot.send_match_reminder(self.FakePlayer(), self.FakeMatch())) is True + assert asyncio.run(bot.send_tryout_reminder(self.FakePlayer(), self.FakeTryout())) is True + + def test_a_bounced_reminder_reports_false(self, bot): + bot.library_cache[5] = FakeUser(5, raises=forbidden()) + + assert asyncio.run(bot.send_match_reminder(self.FakePlayer(), self.FakeMatch())) is False + assert asyncio.run(bot.send_tryout_reminder(self.FakePlayer(), self.FakeTryout())) is False diff --git a/tests/test_static_caching.py b/tests/test_static_caching.py new file mode 100644 index 0000000..67854a4 --- /dev/null +++ b/tests/test_static_caching.py @@ -0,0 +1,104 @@ +"""Static URLs carry a version stamp, so nginx may cache them (PERF-006). + +The audit asked for three lines of nginx: serve /static/ from disk with a +30-day expiry. Enabling that alone would have been a regression. The CSS and +the JS are referenced by a fixed URL, so a month-long cache means a +month-old stylesheet after every deploy, with no way to invalidate it short +of asking people to hard-refresh. + +The stamp is what makes the caching safe: a changed file gets a new URL, and +the cached copy of the old one is simply never requested again. Delete the +stamp and the nginx block becomes a bug — which is the only reason these +tests exist. +""" + +import os +import re + +import pytest +from flask import url_for + + +@pytest.fixture +def nginx_conf(): + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + with open(os.path.join(root, 'app', 'nginx.conf'), encoding='utf-8') as handle: + return handle.read() + + +class TestVersionStamp: + def test_static_urls_carry_a_stamp(self, app): + with app.test_request_context(): + url = url_for('static', filename='css/style.css') + + assert re.search(r'\?v=\d+$', url), f'no cache-busting stamp in {url}' + + def test_a_touched_file_gets_a_new_url(self, app): + """The whole point: redeploying a file must change its URL. + + The stamp is memoised per process — the process restarts on deploy, + which is exactly when a file can have changed — so this drives a + fresh application rather than touching the file under a live one. + """ + from app.app import create_app + + path = os.path.join(app.static_folder, 'css/style.css') + original = os.stat(path) + + with app.test_request_context(): + before = url_for('static', filename='css/style.css') + + os.utime(path, (original.st_atime, original.st_mtime + 60)) + try: + second = create_app(dict(app.config)) + with second.test_request_context(): + after = url_for('static', filename='css/style.css') + finally: + os.utime(path, (original.st_atime, original.st_mtime)) + + assert before != after + + def test_a_missing_file_still_builds_a_url(self, app): + """A template naming a file that is not there must 404, not 500.""" + with app.test_request_context(): + url = url_for('static', filename='css/does-not-exist.css') + + assert url.endswith('does-not-exist.css'), 'no stamp, and no exception either' + + def test_other_endpoints_are_untouched(self, app): + with app.test_request_context(): + assert '?v=' not in url_for('main.index') + + def test_the_stylesheet_and_the_script_are_versioned_in_the_page(self, client, app): + """The stamp is worthless if the layout bypasses url_for.""" + page = client.get('/auth/login').get_data(as_text=True) + + assert re.search(r'style\.css\?v=\d+', page) + assert re.search(r'main\.js\?v=\d+', page) + + +class TestNginx: + def test_the_static_block_is_live(self, nginx_conf): + block = re.search(r'^\s*location /static/ \{', nginx_conf, re.MULTILINE) + assert block, 'the /static/ block is commented out again — 59 KB per page load' + + def test_caching_headers_are_present(self, nginx_conf): + static_block = nginx_conf.split('location /static/')[1].split('\n }')[0] + + assert 'expires 30d' in static_block + assert 'immutable' in static_block + + def test_security_headers_survive_the_block(self, nginx_conf): + """One add_header in a location drops every inherited one. + + nginx only inherits add_header from the enclosing block when the + current block declares none of its own. Setting Cache-Control here + therefore removes nosniff from the JavaScript unless it is repeated. + """ + static_block = nginx_conf.split('location /static/')[1].split('\n }')[0] + + assert 'X-Content-Type-Options' in static_block, ( + 'add_header here cancels the inherited security headers; nosniff ' + 'has to be repeated inside the block' + ) + assert 'Strict-Transport-Security' in static_block From d8541678a63464de89ea4594d0892b22833c39c3 Mon Sep 17 00:00:00 2001 From: GGThed Date: Tue, 11 Aug 2026 12:14:50 -0400 Subject: [PATCH 44/75] fix(auth): retirer un CAPTCHA qui ne protegeait rien, filtrer autrement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEC-AUTH-008. L addition a deux operandes entre 1 et 10 avait dix-neuf reponses possibles et se resolvait en lisant la question comme une chaine. Elle n arretait aucune inscription automatisee. Elle ajoutait en revanche une etape a chaque personne, lecteur d ecran compris, contre une apparence de protection — ce qui est pire que rien, puisque ca se compte comme une protection. L autre branche proposee par l audit etait un vrai service de CAPTCHA : un tiers, une cle d API, une requete a chaque affichage, et un script etranger remis dans script-src, defaisant le travail qui a ferme SEC-WEB-001. Disproportionne pour le site d un club. A la place, deux verifications invisibles pour un visiteur : un champ piege, cache par la feuille de style et hors du parcours clavier, qu un robot remplisseur complete et qu une personne ne voit jamais ; et un delai minimal entre la remise du formulaire et son retour, l horodatage etant dans la session signee et non dans un champ. Le plafond est dit dans le code plutot que sous-entendu : ceci arrete le pourriel de masse, pas quelqu un qui lit la page. Ce qui filtrerait vraiment les inscriptions serait l activation des comptes par le staff — is_active_account vaut True par defaut. C est une decision de produit. L angle « session forgeable » du constat tombe : avec SECRET_KEY compromise (SEC-001), on forge une session connectee sur n importe quel compte et on n a aucune raison de s inscrire. Au passage, ARCH-005 en partie : le bloc « regenerer, purger les mots de passe, re-rendre » etait recopie quatre fois. Un seul helper, et la purge des mots de passe ne peut plus etre oubliee dans la cinquieme copie. Les refus sont journalises avec leur motif — c est le seul endroit ou un abus du formulaire devient visible — mais restent indistinguables pour l expediteur : nommer la regle indique comment la contourner. 429 tests. --- app/routes/auth.py | 191 ++++---- app/static/css/style.css | 9 + app/templates/pages/register.html | 11 +- app/translations/en/LC_MESSAGES/messages.mo | Bin 43862 -> 43864 bytes app/translations/en/LC_MESSAGES/messages.po | 470 ++++++++++---------- app/translations/fr/LC_MESSAGES/messages.mo | Bin 48065 -> 48062 bytes app/translations/fr/LC_MESSAGES/messages.po | 470 ++++++++++---------- tests/test_registration_screening.py | 217 +++++++++ tests/test_transactions.py | 11 +- 9 files changed, 823 insertions(+), 556 deletions(-) create mode 100644 tests/test_registration_screening.py diff --git a/app/routes/auth.py b/app/routes/auth.py index ff0d383..0bff41d 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -2,12 +2,12 @@ This module handles user authentication including login with account lockout protection, logout with session clearing, and new user registration with -password policy enforcement and CAPTCHA verification. +password policy enforcement and sign-up screening. """ import os import secrets -import uuid +import time from datetime import datetime, timedelta from urllib.parse import urlencode, urlparse @@ -38,6 +38,18 @@ MAX_LOCKOUT_MINUTES = 240 #: response time stops telling a caller which usernames exist (SEC-017). _ABSENT_USER_HASH = None +#: Session key recording when the registration form was handed out. +REGISTRATION_ISSUED_KEY = 'registration_form_issued_at' + +#: Name of the honeypot input. Plausible enough that a form-filler wants it, +#: absent from the visible form. Hidden by .honeypot in style.css — not by an +#: inline style, so that the rule survives a tightening of style-src. +REGISTRATION_HONEYPOT_FIELD = 'website' + +#: Floor on how long a genuine registration takes. Eleven fields and a +#: password typed twice; three seconds is generous. +MIN_REGISTRATION_SECONDS = 3 + # Discord OAuth2 configuration DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID') DISCORD_CLIENT_SECRET = os.getenv('DISCORD_CLIENT_SECRET') @@ -116,41 +128,65 @@ def cooloff_minutes(failed_attempts): return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES) -def generate_captcha(): - """Generate a simple math CAPTCHA challenge. +def issue_registration_challenge(): + """Mark that the registration form has been handed out, and when. - Creates a random addition problem and stores the answer in the session. + Kept in the signed session rather than in a form field, so that the + timestamp is not something the submitter can choose. Left in place across + a failed submission: someone correcting a typo should not be told to slow + down, and a robot has already paid for the round trip by then. + """ + session.setdefault(REGISTRATION_ISSUED_KEY, time.time()) + + +def check_registration_challenge(form): + """Say why this registration should be refused, or None to accept. + + What replaced the arithmetic CAPTCHA, and why (SEC-AUTH-008). + + `a + b = ?` with both operands between 1 and 10 has nineteen possible + answers and is solvable by reading the string. It stopped no automated + registration whatsoever. What it did do was add a step for every human, + including anyone using a screen reader, in exchange for an appearance of + protection — which is worse than no protection, because it gets counted + as one. + + The audit's alternative was a real CAPTCHA service. That means a third + party, an API key, a request on every page load, and putting a foreign + script back into script-src — undoing the CSP work that closed + SEC-WEB-001. Disproportionate for a club site. + + So: two checks that cost the visitor nothing. + + - a honeypot field, hidden in the stylesheet, that a form-filling + robot completes and a person never sees; + - a minimum dwell time between being handed the form and sending it + back. Eleven fields and a password typed twice do not get filled in + under three seconds, and a POST with no issued form at all never + fetched the page. + + Be clear about the ceiling: this stops commodity spam, not somebody who + looks at the form for five minutes. The thing that would actually gate + registration is staff activation of new accounts, which does not exist — + `is_active_account` defaults to True. That is a product decision, not one + to slip in here. + + The session-forgery angle in the constat is moot: with SECRET_KEY + compromised (SEC-001) an attacker forges a logged-in session for any + account and has no reason to register at all. Returns: - dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys. + str | None: a short reason for the log, or None to let it through. """ - import random + if form.get(REGISTRATION_HONEYPOT_FIELD, '').strip(): + return 'honeypot' - a = random.randint(1, 10) - b = random.randint(1, 10) - captcha_id = str(uuid.uuid4()) - session['captcha_id'] = captcha_id - session['captcha_answer'] = a + b - return {'question': f'{a} + {b} = ?', 'id': captcha_id} - - -def verify_captcha(user_answer): - """Verify the CAPTCHA answer from the session. - - Args: - user_answer: The user's submitted answer (string or int). - - Returns: - bool: True if the answer matches the stored CAPTCHA, False otherwise. - """ - try: - expected = session.pop('captcha_answer', None) - session.pop('captcha_id', None) - if expected is None: - return False - return int(user_answer) == expected - except (ValueError, TypeError): - return False + issued_at = session.get(REGISTRATION_ISSUED_KEY) + if issued_at is None: + return 'no-form-issued' + if time.time() - issued_at < MIN_REGISTRATION_SECONDS: + return 'too-fast' + return None auth_bp = Blueprint('auth', __name__, url_prefix='/auth') @@ -276,14 +312,33 @@ def login(): return render_template('pages/login.html') +def _rerender_registration(form_data): + """Re-render the registration form after a refusal. + + Was copied out four times, near-identically (ARCH-005). Dropping the two + password fields is the part that must not be forgotten in the fifth copy: + echoing a password back into the HTML puts it in the browser's cache and + in any proxy log along the way. + """ + form_data = dict(form_data) + form_data.pop('password', None) + form_data.pop('confirm_password', None) + return render_template( + 'pages/register.html', + esport_games=ESPORT_GAMES, + honeypot_field=REGISTRATION_HONEYPOT_FIELD, + form_data=form_data, + ) + + @auth_bp.route('/register', methods=['GET', 'POST']) @limiter.limit("20 per hour") def register(): - """Handle new player registration with CAPTCHA and password policy. + """Handle new player registration. - GET: Render the registration form with E-Sports games list and CAPTCHA. - POST: Validate all inputs, verify CAPTCHA, enforce password policy, - and create a new player account. + GET: Render the registration form with the E-Sports games list. + POST: Screen the submission (see check_registration_challenge), validate + every input against RegisterSchema, and create a new player account. Only players can register through this form. Validates username/email uniqueness and password confirmation. @@ -299,20 +354,15 @@ def register(): form_data = dict(request.form) form_data['games'] = request.form.getlist('games') - # Validate CAPTCHA first - captcha_answer = request.form.get('captcha_answer', '') - if not verify_captcha(captcha_answer): - flash(_('Incorrect CAPTCHA answer. Please try again.'), 'danger') - captcha = generate_captcha() - # Clear password fields only on CAPTCHA failure - form_data.pop('password', None) - form_data.pop('confirm_password', None) - return render_template( - 'pages/register.html', - esport_games=ESPORT_GAMES, - captcha=captcha, - form_data=form_data, - ) + refusal = check_registration_challenge(request.form) + if refusal is not None: + # Logged, because this is the only place abuse of the sign-up + # form becomes visible at all. Deliberately vague to the sender: + # naming the honeypot tells whoever tripped it how to avoid it. + log_auth_event('account.registration_refused', reason=refusal) + flash(_('Your registration could not be processed. Please try again.'), 'danger') + issue_registration_challenge() + return _rerender_registration(form_data) # Validate input with marshmallow schema register_schema = RegisterSchema() @@ -322,16 +372,7 @@ def register(): for field, messages in err.messages.items(): for msg in messages: flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger') - captcha = generate_captcha() - # Clear password fields on validation failure - form_data.pop('password', None) - form_data.pop('confirm_password', None) - return render_template( - 'pages/register.html', - esport_games=ESPORT_GAMES, - captcha=captcha, - form_data=form_data, - ) + return _rerender_registration(form_data) username = validated['username'] email = validated['email'] @@ -345,27 +386,11 @@ def register(): if User.query.filter_by(username=username).first(): flash(_('Username already exists.'), 'danger') - captcha = generate_captcha() - form_data.pop('password', None) - form_data.pop('confirm_password', None) - return render_template( - 'pages/register.html', - esport_games=ESPORT_GAMES, - captcha=captcha, - form_data=form_data, - ) + return _rerender_registration(form_data) if User.query.filter_by(email=email).first(): flash(_('Email already registered.'), 'danger') - captcha = generate_captcha() - form_data.pop('password', None) - form_data.pop('confirm_password', None) - return render_template( - 'pages/register.html', - esport_games=ESPORT_GAMES, - captcha=captcha, - form_data=form_data, - ) + return _rerender_registration(form_data) hashed_password = hash_password(password) user = Player( @@ -404,6 +429,7 @@ def register(): # Clear Discord OAuth data from session after successful registration session.pop('discord_oauth', None) + session.pop(REGISTRATION_ISSUED_KEY, None) log_auth_event('account.registered', username=user.username, user_id=user.id) @@ -411,13 +437,8 @@ def register(): return redirect(url_for('auth.login')) # GET request — render empty form - captcha = generate_captcha() - return render_template( - 'pages/register.html', - esport_games=ESPORT_GAMES, - captcha=captcha, - form_data={}, - ) + issue_registration_challenge() + return _rerender_registration({}) @auth_bp.route('/discord/login') diff --git a/app/static/css/style.css b/app/static/css/style.css index 48cff1e..20d2aea 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -2021,3 +2021,12 @@ a:hover { color: var(--primary-dark); } white-space: nowrap; border: 0; } + +/* Honeypot: hidden from everyone, filled in only by a robot (SEC-AUTH-008). + The opposite of .sr-only above — that one hides from the eye and keeps the + announcement, this one has to hide from both. display:none is deliberate: + an off-screen input is still reachable by keyboard and by a screen reader, + and a person who lands in it gets refused with no idea why. */ +.honeypot { + display: none; +} diff --git a/app/templates/pages/register.html b/app/templates/pages/register.html index dc8a03b..9534206 100644 --- a/app/templates/pages/register.html +++ b/app/templates/pages/register.html @@ -146,9 +146,14 @@ required>
-
- - + {# Honeypot (SEC-AUTH-008). Not a field anyone is meant to see or fill: + hidden in the stylesheet, kept out of the tab order, and told to + screen readers to skip. A submission that carries a value here is a + robot filling every input it can find. + Do not add a label, do not translate the name, do not remove + aria-hidden — each of those turns it into a trap for a person. #} + diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 87c678d579cab70e39682040c6e608f99fc2e429..780c525e23029e98a731266b32ba5d640a07a6cc 100644 GIT binary patch delta 10286 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\n" "Language: en\n" @@ -19,7 +19,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" -#: app/validators.py:49 +#: app/validators.py:50 msgid "" "Password must be at least 8 characters with uppercase, lowercase, and a " "number." @@ -27,95 +27,95 @@ msgstr "" "Password must be at least 8 characters with uppercase, lowercase, and a " "number." -#: app/validators.py:67 +#: app/validators.py:68 msgid "Username must be 3-30 characters (letters, numbers, underscore, hyphen)." msgstr "Username must be 3-30 characters (letters, numbers, underscore, hyphen)." -#: app/validators.py:86 +#: app/validators.py:87 msgid "Invalid Discord username format." msgstr "Invalid Discord username format." -#: app/validators.py:103 +#: app/validators.py:104 msgid "Discord User ID must be a 17-20 digit number." msgstr "Discord User ID must be a 17-20 digit number." -#: app/validators.py:121 +#: app/validators.py:122 msgid "Invalid phone number format." msgstr "Invalid phone number format." -#: app/validators.py:163 +#: app/validators.py:164 msgid "Username is required." msgstr "Username is required." -#: app/validators.py:167 +#: app/validators.py:168 msgid "Password is required." msgstr "Password is required." -#: app/validators.py:189 app/validators.py:261 +#: app/validators.py:190 app/validators.py:262 msgid "Username must be 3-80 characters." msgstr "Username must be 3-80 characters." -#: app/validators.py:195 +#: app/validators.py:196 msgid "Email must be 120 characters or less." msgstr "Email must be 120 characters or less." -#: app/validators.py:208 app/validators.py:276 app/validators.py:307 -#: app/validators.py:371 +#: app/validators.py:209 app/validators.py:277 app/validators.py:308 +#: app/validators.py:372 msgid "Full name is required." msgstr "Full name is required." -#: app/validators.py:243 +#: app/validators.py:244 msgid "Passwords do not match." msgstr "Passwords do not match." -#: app/validators.py:280 app/validators.py:315 +#: app/validators.py:281 app/validators.py:316 msgid "Invalid role selected." msgstr "Invalid role selected." -#: app/validators.py:416 +#: app/validators.py:417 msgid "Player must be selected." msgstr "Player must be selected." -#: app/validators.py:419 +#: app/validators.py:420 msgid "Notes must be 2000 characters or less." msgstr "Notes must be 2000 characters or less." -#: app/validators.py:438 +#: app/validators.py:439 msgid "Date must be in YYYY-MM-DD format." msgstr "Date must be in YYYY-MM-DD format." -#: app/validators.py:443 app/validators.py:470 +#: app/validators.py:444 app/validators.py:471 msgid "Start time must be in HH:MM format." msgstr "Start time must be in HH:MM format." -#: app/validators.py:447 +#: app/validators.py:448 msgid "End time must be in HH:MM format." msgstr "End time must be in HH:MM format." -#: app/validators.py:450 +#: app/validators.py:451 msgid "Points must be 2000 characters or less." msgstr "Points must be 2000 characters or less." -#: app/validators.py:466 +#: app/validators.py:467 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Day must be 0 (Monday) to 6 (Sunday)." -#: app/routes/auth.py:186 app/routes/auth.py:322 app/routes/users.py:106 -#: app/routes/users.py:821 +#: app/routes/auth.py:224 app/routes/auth.py:374 app/routes/users/_shared.py:64 +#: app/routes/users/contracts.py:95 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s: %(msg)s" -#: app/routes/auth.py:203 +#: app/routes/auth.py:241 msgid "This account has been deactivated." msgstr "This account has been deactivated." -#: app/routes/auth.py:238 +#: app/routes/auth.py:276 #, python-format msgid "Welcome back, %(username)s!" msgstr "Welcome back, %(username)s!" -#: app/routes/auth.py:268 +#: app/routes/auth.py:306 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -123,27 +123,27 @@ msgstr "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." -#: app/routes/auth.py:303 -msgid "Incorrect CAPTCHA answer. Please try again." -msgstr "Incorrect CAPTCHA answer. Please try again." +#: app/routes/auth.py:363 +msgid "Your registration could not be processed. Please try again." +msgstr "Your registration could not be processed. Please try again." -#: app/routes/auth.py:345 app/routes/users.py:436 +#: app/routes/auth.py:388 app/routes/users/accounts.py:308 msgid "Username already exists." msgstr "Username already exists." -#: app/routes/auth.py:357 app/routes/users.py:440 +#: app/routes/auth.py:392 app/routes/users/accounts.py:312 msgid "Email already registered." msgstr "Email already registered." -#: app/routes/auth.py:404 +#: app/routes/auth.py:436 msgid "Your account has been created! You can now log in." msgstr "Your account has been created! You can now log in." -#: app/routes/auth.py:430 +#: app/routes/auth.py:457 msgid "Discord OAuth2 is not configured." msgstr "Discord OAuth2 is not configured." -#: app/routes/auth.py:471 +#: app/routes/auth.py:498 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -151,418 +151,419 @@ msgstr "" "Discord authorization could not be verified. Please start the connection " "again from this page." -#: app/routes/auth.py:480 +#: app/routes/auth.py:507 msgid "Discord authorization failed. No code received." msgstr "Discord authorization failed. No code received." -#: app/routes/auth.py:504 +#: app/routes/auth.py:531 msgid "Failed to connect to Discord. Please try again." msgstr "Failed to connect to Discord. Please try again." -#: app/routes/auth.py:508 +#: app/routes/auth.py:535 msgid "Failed to obtain Discord access token." msgstr "Failed to obtain Discord access token." -#: app/routes/auth.py:523 +#: app/routes/auth.py:550 msgid "Failed to fetch Discord user profile." msgstr "Failed to fetch Discord user profile." -#: app/routes/auth.py:570 +#: app/routes/auth.py:597 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Discord account connected! Your profile has been pre-filled." -#: app/routes/auth.py:598 +#: app/routes/auth.py:625 msgid "You have been logged out." msgstr "You have been logged out." -#: app/routes/evaluations.py:45 +#: app/routes/evaluations.py:46 msgid "You do not have permission to view evaluations." msgstr "You do not have permission to view evaluations." -#: app/routes/evaluations.py:135 +#: app/routes/evaluations.py:136 msgid "You do not have permission to evaluate players." msgstr "You do not have permission to evaluate players." -#: app/routes/evaluations.py:140 app/routes/evaluations.py:263 +#: app/routes/evaluations.py:141 app/routes/evaluations.py:264 msgid "You do not have permission to evaluate players in this tryout." msgstr "You do not have permission to evaluate players in this tryout." -#: app/routes/evaluations.py:151 +#: app/routes/evaluations.py:152 msgid "Player is not registered for this tryout." msgstr "Player is not registered for this tryout." -#: app/routes/evaluations.py:156 +#: app/routes/evaluations.py:157 msgid "Can only evaluate players." msgstr "Can only evaluate players." -#: app/routes/evaluations.py:208 +#: app/routes/evaluations.py:209 msgid "Evaluation updated!" msgstr "Evaluation updated!" -#: app/routes/evaluations.py:228 +#: app/routes/evaluations.py:229 msgid "Evaluation submitted successfully!" msgstr "Evaluation submitted successfully!" -#: app/routes/evaluations.py:258 app/routes/teams.py:268 -#: app/routes/teams.py:309 app/routes/teams.py:350 app/routes/teams.py:375 -#: app/routes/teams.py:400 app/routes/teams.py:435 app/routes/tryouts.py:472 -#: app/routes/tryouts.py:488 app/routes/tryouts.py:508 -#: app/routes/tryouts.py:547 app/routes/tryouts.py:583 -#: app/routes/tryouts.py:602 +#: app/routes/evaluations.py:259 app/routes/teams.py:270 +#: app/routes/teams.py:311 app/routes/teams.py:352 app/routes/teams.py:377 +#: app/routes/teams.py:402 app/routes/teams.py:437 app/routes/tryouts.py:505 +#: app/routes/tryouts.py:521 app/routes/tryouts.py:541 +#: app/routes/tryouts.py:580 app/routes/tryouts.py:616 +#: app/routes/tryouts.py:635 msgid "Permission denied." msgstr "Permission denied." -#: app/routes/main.py:53 +#: app/routes/main.py:55 msgid "That language is not available." msgstr "That language is not available." -#: app/routes/matches.py:245 +#: app/routes/matches.py:307 msgid "You do not have permission to schedule matches for this tryout." msgstr "You do not have permission to schedule matches for this tryout." -#: app/routes/matches.py:249 app/routes/matches.py:422 +#: app/routes/matches.py:311 app/routes/matches.py:473 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "This tryout has ended. Matches can no longer be created or modified." -#: app/routes/matches.py:270 +#: app/routes/matches.py:332 msgid "Start time is required. Please select a time slot." msgstr "Start time is required. Please select a time slot." -#: app/routes/matches.py:282 app/routes/matches.py:445 -#: app/routes/team_matches.py:151 app/routes/team_matches.py:255 +#: app/routes/matches.py:344 app/routes/matches.py:496 +#: app/routes/team_matches.py:153 app/routes/team_matches.py:247 msgid "Invalid date format." msgstr "Invalid date format." -#: app/routes/matches.py:302 app/routes/team_matches.py:172 +#: app/routes/matches.py:364 app/routes/team_matches.py:174 msgid "Invalid time format." msgstr "Invalid time format." -#: app/routes/matches.py:398 +#: app/routes/matches.py:449 msgid "Match scheduled successfully!" msgstr "Match scheduled successfully!" -#: app/routes/matches.py:418 app/routes/team_matches.py:242 +#: app/routes/matches.py:469 app/routes/team_matches.py:234 msgid "You do not have permission to edit this match." msgstr "You do not have permission to edit this match." -#: app/routes/matches.py:456 +#: app/routes/matches.py:507 msgid "Start time is required." msgstr "Start time is required." -#: app/routes/matches.py:578 app/routes/team_matches.py:284 +#: app/routes/matches.py:616 app/routes/team_matches.py:276 msgid "Match updated successfully!" msgstr "Match updated successfully!" -#: app/routes/matches.py:631 app/routes/team_matches.py:299 +#: app/routes/matches.py:669 app/routes/team_matches.py:291 msgid "You do not have permission to delete this match." msgstr "You do not have permission to delete this match." -#: app/routes/matches.py:634 +#: app/routes/matches.py:672 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "This tryout has ended. Matches can no longer be deleted." -#: app/routes/matches.py:647 app/routes/team_matches.py:303 +#: app/routes/matches.py:685 app/routes/team_matches.py:295 msgid "Match deleted successfully." msgstr "Match deleted successfully." -#: app/routes/team_matches.py:98 +#: app/routes/team_matches.py:100 msgid "You do not have permission to schedule matches for this team." msgstr "You do not have permission to schedule matches for this team." -#: app/routes/team_matches.py:140 +#: app/routes/team_matches.py:142 msgid "Date is required." msgstr "Date is required." -#: app/routes/team_matches.py:228 +#: app/routes/team_matches.py:220 #, python-format msgid "Team match \"%(title)s\" scheduled successfully!" msgstr "Team match \"%(title)s\" scheduled successfully!" -#: app/routes/team_matches.py:267 +#: app/routes/team_matches.py:259 msgid "Invalid start time format." msgstr "Invalid start time format." -#: app/routes/team_matches.py:275 +#: app/routes/team_matches.py:267 msgid "Invalid end time format." msgstr "Invalid end time format." -#: app/routes/teams.py:38 +#: app/routes/teams.py:40 msgid "Use My Team(s) to view your teams." msgstr "Use My Team(s) to view your teams." -#: app/routes/teams.py:41 +#: app/routes/teams.py:43 msgid "You do not have permission to view teams." msgstr "You do not have permission to view teams." -#: app/routes/teams.py:68 +#: app/routes/teams.py:70 msgid "This page is for players." msgstr "This page is for players." -#: app/routes/teams.py:121 +#: app/routes/teams.py:123 msgid "You do not have permission to create teams." msgstr "You do not have permission to create teams." -#: app/routes/teams.py:129 app/routes/teams.py:174 +#: app/routes/teams.py:131 app/routes/teams.py:176 msgid "Team name is required." msgstr "Team name is required." -#: app/routes/teams.py:134 app/routes/teams.py:179 +#: app/routes/teams.py:136 app/routes/teams.py:181 #, python-format msgid "Team \"%(name)s\" already exists." msgstr "Team \"%(name)s\" already exists." -#: app/routes/teams.py:156 +#: app/routes/teams.py:158 #, python-format msgid "Team \"%(name)s\" created successfully!" msgstr "Team \"%(name)s\" created successfully!" -#: app/routes/teams.py:166 +#: app/routes/teams.py:168 msgid "You do not have permission to edit this team." msgstr "You do not have permission to edit this team." -#: app/routes/teams.py:217 +#: app/routes/teams.py:219 #, python-format msgid "Team \"%(name)s\" updated successfully!" msgstr "Team \"%(name)s\" updated successfully!" -#: app/routes/teams.py:226 +#: app/routes/teams.py:228 msgid "You do not have permission to delete teams." msgstr "You do not have permission to delete teams." -#: app/routes/teams.py:258 +#: app/routes/teams.py:260 #, python-format msgid "Team \"%(name)s\" deleted successfully." msgstr "Team \"%(name)s\" deleted successfully." -#: app/routes/teams.py:273 +#: app/routes/teams.py:275 msgid "Please select a coach." msgstr "Please select a coach." -#: app/routes/teams.py:278 +#: app/routes/teams.py:280 msgid "Only coaches can be assigned as coach." msgstr "Only coaches can be assigned as coach." -#: app/routes/teams.py:284 +#: app/routes/teams.py:286 #, python-format msgid "%(username)s is already a coach of %(name)s." msgstr "%(username)s is already a coach of %(name)s." -#: app/routes/teams.py:297 +#: app/routes/teams.py:299 #, python-format msgid "%(username)s added as coach of %(name)s." msgstr "%(username)s added as coach of %(name)s." -#: app/routes/teams.py:314 +#: app/routes/teams.py:316 msgid "Please select a manager." msgstr "Please select a manager." -#: app/routes/teams.py:319 +#: app/routes/teams.py:321 msgid "Only managers can be assigned as manager." msgstr "Only managers can be assigned as manager." -#: app/routes/teams.py:325 +#: app/routes/teams.py:327 #, python-format msgid "%(username)s is already a manager of %(name)s." msgstr "%(username)s is already a manager of %(name)s." -#: app/routes/teams.py:338 +#: app/routes/teams.py:340 #, python-format msgid "%(username)s added as manager of %(name)s." msgstr "%(username)s added as manager of %(name)s." -#: app/routes/teams.py:365 +#: app/routes/teams.py:367 #, python-format msgid "Coach removed from %(name)s." msgstr "Coach removed from %(name)s." -#: app/routes/teams.py:390 +#: app/routes/teams.py:392 #, python-format msgid "Manager removed from %(name)s." msgstr "Manager removed from %(name)s." -#: app/routes/teams.py:406 app/routes/tryouts.py:512 app/routes/tryouts.py:613 +#: app/routes/teams.py:408 app/routes/tryouts.py:545 app/routes/tryouts.py:646 msgid "Please select a player." msgstr "Please select a player." -#: app/routes/teams.py:411 +#: app/routes/teams.py:413 msgid "Can only assign players to teams." msgstr "Can only assign players to teams." -#: app/routes/teams.py:417 +#: app/routes/teams.py:419 #, python-format msgid "%(username)s is already on %(name)s." msgstr "%(username)s is already on %(name)s." -#: app/routes/teams.py:425 +#: app/routes/teams.py:427 #, python-format msgid "%(username)s added to %(name)s!" msgstr "%(username)s added to %(name)s!" -#: app/routes/teams.py:442 app/routes/teams.py:515 +#: app/routes/teams.py:444 app/routes/teams.py:517 #, python-format msgid "%(username)s is not on %(name)s." msgstr "%(username)s is not on %(name)s." -#: app/routes/teams.py:450 +#: app/routes/teams.py:452 #, python-format msgid "%(username)s removed from %(name)s." msgstr "%(username)s removed from %(name)s." -#: app/routes/teams.py:486 app/routes/teams.py:504 +#: app/routes/teams.py:488 app/routes/teams.py:506 msgid "You do not have permission to add notes to this team." msgstr "You do not have permission to add notes to this team." -#: app/routes/teams.py:494 +#: app/routes/teams.py:496 msgid "Team notes added successfully!" msgstr "Team notes added successfully!" -#: app/routes/teams.py:509 app/routes/users.py:1530 app/routes/users.py:1573 +#: app/routes/teams.py:511 app/routes/users/notes.py:207 +#: app/routes/users/notes.py:250 msgid "Can only add notes for players." msgstr "Can only add notes for players." -#: app/routes/teams.py:525 +#: app/routes/teams.py:527 #, python-format msgid "Note added for %(username)s!" msgstr "Note added for %(username)s!" -#: app/routes/tryouts.py:56 +#: app/routes/tryouts.py:77 msgid "You do not have permission to create tryouts." msgstr "You do not have permission to create tryouts." -#: app/routes/tryouts.py:82 app/routes/tryouts.py:189 +#: app/routes/tryouts.py:103 app/routes/tryouts.py:210 msgid "Invalid start date format." msgstr "Invalid start date format." -#: app/routes/tryouts.py:97 app/routes/tryouts.py:204 +#: app/routes/tryouts.py:118 app/routes/tryouts.py:225 msgid "End date cannot be before start date." msgstr "End date cannot be before start date." -#: app/routes/tryouts.py:107 app/routes/tryouts.py:214 +#: app/routes/tryouts.py:128 app/routes/tryouts.py:235 msgid "Invalid end date format." msgstr "Invalid end date format." -#: app/routes/tryouts.py:139 +#: app/routes/tryouts.py:160 msgid "Tryout created successfully!" msgstr "Tryout created successfully!" -#: app/routes/tryouts.py:159 +#: app/routes/tryouts.py:180 msgid "You do not have permission to edit this tryout." msgstr "You do not have permission to edit this tryout." -#: app/routes/tryouts.py:163 +#: app/routes/tryouts.py:184 msgid "This tryout has ended and can no longer be modified." msgstr "This tryout has ended and can no longer be modified." -#: app/routes/tryouts.py:242 +#: app/routes/tryouts.py:263 msgid "Tryout updated successfully!" msgstr "Tryout updated successfully!" -#: app/routes/tryouts.py:289 +#: app/routes/tryouts.py:310 msgid "You do not have permission to view this tryout." msgstr "You do not have permission to view this tryout." -#: app/routes/tryouts.py:439 +#: app/routes/tryouts.py:472 msgid "Only players can register for tryouts." msgstr "Only players can register for tryouts." -#: app/routes/tryouts.py:443 +#: app/routes/tryouts.py:476 msgid "This tryout is not accepting registrations." msgstr "This tryout is not accepting registrations." -#: app/routes/tryouts.py:450 +#: app/routes/tryouts.py:483 msgid "You are already registered for this tryout." msgstr "You are already registered for this tryout." -#: app/routes/tryouts.py:456 app/routes/tryouts.py:531 +#: app/routes/tryouts.py:489 app/routes/tryouts.py:564 msgid "This tryout is full." msgstr "This tryout is full." -#: app/routes/tryouts.py:462 +#: app/routes/tryouts.py:495 msgid "Successfully registered for tryout!" msgstr "Successfully registered for tryout!" -#: app/routes/tryouts.py:478 +#: app/routes/tryouts.py:511 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Tryout status updated to %(new_status)s." -#: app/routes/tryouts.py:498 +#: app/routes/tryouts.py:531 msgid "Registration status updated." msgstr "Registration status updated." -#: app/routes/tryouts.py:517 +#: app/routes/tryouts.py:550 msgid "Can only register players." msgstr "Can only register players." -#: app/routes/tryouts.py:523 +#: app/routes/tryouts.py:556 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s is already registered for this tryout." -#: app/routes/tryouts.py:537 +#: app/routes/tryouts.py:570 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s registered for tryout!" -#: app/routes/tryouts.py:573 +#: app/routes/tryouts.py:606 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s removed from tryout." -#: app/routes/tryouts.py:591 +#: app/routes/tryouts.py:624 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Team \"%(team_name)s\" created!" -#: app/routes/tryouts.py:622 +#: app/routes/tryouts.py:655 msgid "That player is not registered for this tryout." msgstr "That player is not registered for this tryout." -#: app/routes/tryouts.py:628 +#: app/routes/tryouts.py:661 msgid "Player is already on this team." msgstr "Player is already on this team." -#: app/routes/tryouts.py:633 +#: app/routes/tryouts.py:666 msgid "Player added to team!" msgstr "Player added to team!" -#: app/routes/tryouts.py:643 +#: app/routes/tryouts.py:676 msgid "You do not have permission to delete this tryout." msgstr "You do not have permission to delete this tryout." -#: app/routes/tryouts.py:679 +#: app/routes/tryouts.py:712 msgid "Tryout deleted successfully." msgstr "Tryout deleted successfully." -#: app/routes/users.py:83 +#: app/routes/users/_shared.py:46 msgid "No file selected." msgstr "No file selected." -#: app/routes/users.py:87 +#: app/routes/users/_shared.py:50 msgid "Only PDF files are allowed for contracts." msgstr "Only PDF files are allowed for contracts." -#: app/routes/users.py:92 +#: app/routes/users/_shared.py:55 msgid "That file is not a PDF, whatever its name says." msgstr "That file is not a PDF, whatever its name says." -#: app/routes/users.py:181 +#: app/routes/users/accounts.py:53 msgid "Only the president can manage users." msgstr "Only the president can manage users." -#: app/routes/users.py:193 +#: app/routes/users/accounts.py:65 msgid "Only the president can edit users." msgstr "Only the president can edit users." -#: app/routes/users.py:234 +#: app/routes/users/accounts.py:106 msgid "Email already in use by another account." msgstr "Email already in use by another account." -#: app/routes/users.py:245 +#: app/routes/users/accounts.py:117 msgid "You cannot change your own role. Ask another president to do it." msgstr "You cannot change your own role. Ask another president to do it." -#: app/routes/users.py:258 +#: app/routes/users/accounts.py:130 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -570,175 +571,176 @@ msgstr "" "This is the last active president. Promote another account before " "changing this one." -#: app/routes/users.py:337 +#: app/routes/users/accounts.py:209 #, python-format msgid "User %(username)s updated successfully!" msgstr "User %(username)s updated successfully!" -#: app/routes/users.py:358 +#: app/routes/users/accounts.py:230 msgid "Only the president can delete users." msgstr "Only the president can delete users." -#: app/routes/users.py:362 +#: app/routes/users/accounts.py:234 msgid "You cannot delete your own account." msgstr "You cannot delete your own account." -#: app/routes/users.py:405 +#: app/routes/users/accounts.py:277 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "User %(deleted_username)s has been removed." -#: app/routes/users.py:416 +#: app/routes/users/accounts.py:288 msgid "Only the president can create users." msgstr "Only the president can create users." -#: app/routes/users.py:464 +#: app/routes/users/accounts.py:336 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "User %(full_name)s created as %(role)s!" -#: app/routes/users.py:534 -msgid "Username already taken." -msgstr "Username already taken." +#: app/routes/users/availability.py:179 +msgid "Only coaches can manage availability." +msgstr "Only coaches can manage availability." -#: app/routes/users.py:544 -msgid "Email already in use." -msgstr "Email already in use." - -#: app/routes/users.py:574 -msgid "Profile updated successfully!" -msgstr "Profile updated successfully!" - -#: app/routes/users.py:809 +#: app/routes/users/contracts.py:83 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Only presidents, managers, and coaches can upload contracts." -#: app/routes/users.py:828 +#: app/routes/users/contracts.py:102 msgid "You do not have permission to upload a contract for this player." msgstr "You do not have permission to upload a contract for this player." -#: app/routes/users.py:869 +#: app/routes/users/contracts.py:143 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contract uploaded successfully for %(username)s!" -#: app/routes/users.py:883 +#: app/routes/users/contracts.py:157 msgid "Only the player can upload their signed contract." msgstr "Only the player can upload their signed contract." -#: app/routes/users.py:902 +#: app/routes/users/contracts.py:176 msgid "Signed contract uploaded successfully!" msgstr "Signed contract uploaded successfully!" -#: app/routes/users.py:912 app/routes/users.py:925 +#: app/routes/users/contracts.py:186 app/routes/users/contracts.py:199 msgid "You do not have permission to download this contract." msgstr "You do not have permission to download this contract." -#: app/routes/users.py:928 +#: app/routes/users/contracts.py:202 msgid "No signed contract available." msgstr "No signed contract available." -#: app/routes/users.py:1034 -msgid "Only players can request One on One sessions." -msgstr "Only players can request One on One sessions." - -#: app/routes/users.py:1047 -msgid "You do not have a coach assigned to your team." -msgstr "You do not have a coach assigned to your team." - -#: app/routes/users.py:1082 -msgid "Cannot request One on One - no coach assigned." -msgstr "Cannot request One on One - no coach assigned." - -#: app/routes/users.py:1090 -msgid "Invalid date or time format." -msgstr "Invalid date or time format." - -#: app/routes/users.py:1104 -msgid "The requested time is not within the coach's availability." -msgstr "The requested time is not within the coach's availability." - -#: app/routes/users.py:1132 -msgid "Your One on One request has been submitted!" -msgstr "Your One on One request has been submitted!" - -#: app/routes/users.py:1176 -msgid "Only coaches can accept One on One requests." -msgstr "Only coaches can accept One on One requests." - -#: app/routes/users.py:1182 app/routes/users.py:1232 -msgid "This request is not for you." -msgstr "This request is not for you." - -#: app/routes/users.py:1186 app/routes/users.py:1236 -msgid "This request has already been processed." -msgstr "This request has already been processed." - -#: app/routes/users.py:1213 -#, python-format -msgid "One on One request from %(player)s has been approved!" -msgstr "One on One request from %(player)s has been approved!" - -#: app/routes/users.py:1226 -msgid "Only coaches can reject One on One requests." -msgstr "Only coaches can reject One on One requests." - -#: app/routes/users.py:1268 -#, python-format -msgid "One on One request from %(player)s has been rejected." -msgstr "One on One request from %(player)s has been rejected." - -#: app/routes/users.py:1286 +#: app/routes/users/notes.py:34 msgid "This page is for players only." msgstr "This page is for players only." -#: app/routes/users.py:1328 -msgid "Only coaches can manage availability." -msgstr "Only coaches can manage availability." - -#: app/routes/users.py:1394 +#: app/routes/users/notes.py:71 msgid "Only coaches can access the notes dashboard." msgstr "Only coaches can access the notes dashboard." -#: app/routes/users.py:1484 +#: app/routes/users/notes.py:161 msgid "Only coaches can manage team notes." msgstr "Only coaches can manage team notes." -#: app/routes/users.py:1490 +#: app/routes/users/notes.py:167 msgid "You are not assigned to a team." msgstr "You are not assigned to a team." -#: app/routes/users.py:1503 +#: app/routes/users/notes.py:180 msgid "Team notes saved successfully!" msgstr "Team notes saved successfully!" -#: app/routes/users.py:1518 +#: app/routes/users/notes.py:195 msgid "Only coaches can manage personal notes." msgstr "Only coaches can manage personal notes." -#: app/routes/users.py:1525 app/routes/users.py:1568 app/routes/users.py:1619 -#: app/routes/users.py:1673 +#: app/routes/users/notes.py:202 app/routes/users/notes.py:245 +#: app/routes/users/notes.py:296 app/routes/users/notes.py:350 msgid "Player and content are required." msgstr "Player and content are required." -#: app/routes/users.py:1534 app/routes/users.py:1577 app/routes/users.py:1623 -#: app/routes/users.py:1677 +#: app/routes/users/notes.py:211 app/routes/users/notes.py:254 +#: app/routes/users/notes.py:300 app/routes/users/notes.py:354 msgid "You can only write notes about players you work with." msgstr "You can only write notes about players you work with." -#: app/routes/users.py:1544 app/routes/users.py:1590 +#: app/routes/users/notes.py:221 app/routes/users/notes.py:267 #, python-format msgid "Note added for %(username)s." msgstr "Note added for %(username)s." -#: app/routes/users.py:1558 app/routes/users.py:1604 app/routes/users.py:1657 +#: app/routes/users/notes.py:235 app/routes/users/notes.py:281 +#: app/routes/users/notes.py:334 msgid "Only coaches can add personal notes." msgstr "Only coaches can add personal notes." -#: app/routes/users.py:1634 app/routes/users.py:1688 +#: app/routes/users/notes.py:311 app/routes/users/notes.py:365 msgid "Note added successfully." msgstr "Note added successfully." +#: app/routes/users/one_on_one.py:20 +msgid "Only players can request One on One sessions." +msgstr "Only players can request One on One sessions." + +#: app/routes/users/one_on_one.py:33 +msgid "You do not have a coach assigned to your team." +msgstr "You do not have a coach assigned to your team." + +#: app/routes/users/one_on_one.py:68 +msgid "Cannot request One on One - no coach assigned." +msgstr "Cannot request One on One - no coach assigned." + +#: app/routes/users/one_on_one.py:76 +msgid "Invalid date or time format." +msgstr "Invalid date or time format." + +#: app/routes/users/one_on_one.py:90 +msgid "The requested time is not within the coach's availability." +msgstr "The requested time is not within the coach's availability." + +#: app/routes/users/one_on_one.py:118 +msgid "Your One on One request has been submitted!" +msgstr "Your One on One request has been submitted!" + +#: app/routes/users/one_on_one.py:163 +msgid "Only coaches can accept One on One requests." +msgstr "Only coaches can accept One on One requests." + +#: app/routes/users/one_on_one.py:169 app/routes/users/one_on_one.py:219 +msgid "This request is not for you." +msgstr "This request is not for you." + +#: app/routes/users/one_on_one.py:173 app/routes/users/one_on_one.py:223 +msgid "This request has already been processed." +msgstr "This request has already been processed." + +#: app/routes/users/one_on_one.py:200 +#, python-format +msgid "One on One request from %(player)s has been approved!" +msgstr "One on One request from %(player)s has been approved!" + +#: app/routes/users/one_on_one.py:213 +msgid "Only coaches can reject One on One requests." +msgstr "Only coaches can reject One on One requests." + +#: app/routes/users/one_on_one.py:255 +#, python-format +msgid "One on One request from %(player)s has been rejected." +msgstr "One on One request from %(player)s has been rejected." + +#: app/routes/users/profile.py:83 +msgid "Username already taken." +msgstr "Username already taken." + +#: app/routes/users/profile.py:93 +msgid "Email already in use." +msgstr "Email already in use." + +#: app/routes/users/profile.py:123 +msgid "Profile updated successfully!" +msgstr "Profile updated successfully!" + #: app/templates/errors/400.html:2 msgid "400 Bad Request" msgstr "400 Bad Request" @@ -2372,11 +2374,7 @@ msgstr "Confirm Password" msgid "Confirm your password" msgstr "Confirm your password" -#: app/templates/pages/register.html:151 -msgid "Answer" -msgstr "Answer" - -#: app/templates/pages/register.html:154 +#: app/templates/pages/register.html:159 msgid "Create Account" msgstr "Create Account" @@ -2876,3 +2874,9 @@ msgstr "View Profile" #~ msgid "Login unsuccessful. %(remaining)s attempt(s) remaining before lockout." #~ msgstr "Login unsuccessful. %(remaining)s attempt(s) remaining before lockout." +#~ msgid "Incorrect CAPTCHA answer. Please try again." +#~ msgstr "Incorrect CAPTCHA answer. Please try again." + +#~ msgid "Answer" +#~ msgstr "Answer" + diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index 6936029d68087e94423bcb235dc58de5fb00caf4..fcd5752cd63c25513642df108945e5b7108ac0d5 100644 GIT binary patch delta 10296 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 Yno{X{r&}??{&mOD$Uv!vp^Z)<= delta 10299 zcmYM(2YgmVy2tTDNN6b}At8i>V(e`fBze*EyAnRBK+^UQf!_wXaHqmR7YtHIvO z9R3;a)RL5hse%aRV+Iq>F=6M)uA+=HCG)FD4Cu;mbwm!zz zT?}P@C!d0LxD~bYqo_z+#8UVGnag>E8n7zy)&zC2G`2;(*A*3^-dGY}!7?}$wd2uP z7U!TAl#i}L`2huWJb>!(B{G+D9(9&~U?q&HZO*s}`cfZ()o=p#z}2Y!|3*dP7u3SM zNes37qUH%fZ8WwH@mC>*hH}^r^EH9 z)RFuRwZQMOJl?eRU(uiXb5!L0++;Ih460*I48;1Vq-%>hyZ+b?$Knt?fc3Bne`vw! zsQ&#?6TX7MI2N^#S*UT}K`me%vIfW9ML`2xMV(aQ!f4bHB%=mu zZtGo8N6`=U-s_l#^HAfTLT~&A)&D!>J=eKGp(+i(p+Xu?yp_GtsN{-8EvyqJU^*%% zCZQHE3)|un{1bkM!I;{>Ow<|mUC2P?*htg@Ct+#b|M?WuVKFL+He+r45*2}8QP;B+ z3DFEasEIO>v~Uh#FZ>T`;Ive86g{yb^_NjckcW!oWYoCx(3|<4_b6y(>rgw}jtbd+ z>rqr>&R`;5z)*aGT3BEs6R8@gNTj1CTxMNspKnEN@T{%hM^|SN+Ssft23t{2MxHt2 zP?6b*P4NhZ<5O&bp-oKI_d)Gw3`XHh48aZ98~0%aEXN>W7=@}gY)bsK(#|v}G`&%g z7>t^D6e>4npgL?qE%*Rx!sDorUqVIfHYz6`qK@VTDu5k8 z+wc`?Apyj-CRRqBaeMUOKvYQIz(`z-mGC3$N!xxMwSnhY8AG{)vM#EB7u0RaLT$_) zMnOrJhYHCXs2As8G`^3T@DS?kFJle-8Iv)pB|kivj#|(f)EVzXz5g{Tl0~R-?xK#M z9ARJqt`kZ@p^U8LZGg<9Y$>lTcnUVz%cMO6PQs0dz1O>h?@@ORV^g|s%m zsSEBz%B6!^n1g*Rdh$y?0O%*oexN&uslXlE=;+ zRPrXYH`lH?DhY>R6?AR;n)aN3UmA|npbnKgn4Q)|g{V2|th=Ko9EsjI8x@fS)|IH_ z+HCzC_5L;V!QW9EeU2J8sH0g})sDnJj)qP&C}~Eab~qUu;6m#$tWNzgDwMIEjOnQU zZ(uxb#Api3sDWyu zLfQ-!%GT(Iy-@>aVJRGe%B4K}d?sqbMd-oJ$bwzx1OP!pd*4fq{ug6o)tMd-m!Jm zafnb8m+ox_4n=*l<4_A|i8}LcSQ95;AZ|c?fIh)Ae1P4tZXd^a6KIg#^hJd>6XS6R=Hdd>`@dra^vN)h3%AChk}?VFVF%RxpMn~9Eo$5?sEF@! z?F$Fci-zy5*HDr9Co1&!kn7_7j5^B-e2-;g>mXEC&q7V`fpt47GJCKhevaC}Wz>B;}si=wH#&Fz$iTDNT ztnXn8`VKUIOKypcbpK~j2;sqDR5E>o3i%7vioG*U2+N^%7=cRGde*k6WX-S+K}BpV zDl*eiM=}?6OBSIbvKAwm-`Sx8oHH^W;EVF>l zsD)-&N1%2(%{mVix#g$@Z_6V7+R8;*n&*~FL0KO%%&d3_>cjCaDp`)9KVHNr z{1Fw>7pM=A-*EHI_n;!!%9@Ef^U0WBg7HxASAErNxFM!ecXKId#hb7OevP&8u{HiR zb8UK~+UKI)`xMphM^xm><(dUF#zg9UQMYP3DhZdMl6*HR2hJc1cAXz6=*7pVBnud6 zk}e)&si&cmYA|X?6Ris|l=>#r0t!+0{thYsjsFsAzP#c(=Rb>rI<7&z zxWD)T{|yH<(0@=1C^OpLf7Af=P`9H4Y9U#uaVFaOyBI@#3zor?SPL&;06xcx%KT60xDAXu?dzMWBxwT3ZtnH#Wpw_ zUA=gkf_8cxiJtQeOXF)}%}U3ib}$pm;R4&f90RFuw(i5S)W1Z%e*v4}Kd?N8jWfxc zfJ(-cah$&f>_~&IQ*UgHOHfI67L{bbqZU+kys-vq;CiSt9gPb098`opL_a)<+UX^1 zh2eZ87UKX^#O_TX{-r1cO*A2`gv!<$sP=ZK9d$+RGy^s8F!aaq7=klVJ6?f$e?2M} zwxf>dI2K13ds2Ufq?Oadecjym!>EoAQ4>7HU@SAqBv%9~0?Akb8>60g!vxGiEnqHc z;rXZquS6y1hp6|D*!o3VcZ(=!2T!pg22M61OhDD!V^_>Xo#A%$#zIsCj$kD`i-~v( zwSe+d%zG8FF7*g3jwGf~&p;OBItwU-(XbZ#;Xcg6fT?C-F8Wemis85(l{1AHgkPW{ z^LJanhPA0b#MT%+&4m6HRQq&niaRlj`JEyPsWfzYgRd6OKn?tN)K2bT6?}l&almx* z$&Eza=xDT6Qi5ccdTVP}AnHY&H(A9u@DQJavt)*s~7ostO z_GZWrne&ouKaBCzub@8BUT>P6#iJIIgj#rO)I{A;NB5d_7HUK5-X#8-cqa{7`4^~; zSFkeP#`@^>mf3MbRDCci8CRet+=>cyA*%m5jK(Wi5}%@u?gi?|{AZbkMb09m-ZV6( zK?`Yt8lWF);K3M&6VV4(qjF*c4!~_V2)$>UBO8J`;}fVXKZn}6_Z)M?QK)&kV>KM+ zQqW2lVhrv=efiFzlI{ss#Y%I{LYkr?lZCnkQ?V4zMqSs1sB5+f^_|#_8uxQdz#FKE z{pOkfgTt*yK}k0Pb^SJ@CVqw$q*a*Ge;TW(fcE$Hm zJH3NS)<00!E@qMW{cnL$)F)#mu0-9o-_TW3_`Pc;OhFIzuGk$%qE>tetK$WX$EWDQ zs`)0l+TdX7IjDu7!T`LELHGbQq1R&5zY^*Q(iRhct-LJ_ny4RoFxR>eHPCKUZu|vf z@EGd-o0x)6Fd37Um|KvIx;2wfU%b_*Py8iR1pSwqq;J2J_^abg8uVZ%CQs25@mUP0wRt5s&)p;(^!7!1c57>uh?x5V8|p%R5ctcc&B z7W5Np;$JZo|G)|u{GLgsc&tIa9VTKPYG9!Ov8^+NBaQv ze&AZhV16f-f;!g1S1|=WxDIvZe?@h;fC}{utcCvTOqMr6O_+%~${84q@1c&a5H-$u zRAjGX6}*jZGzFjaCbZR16D46~Y>iQvi5{GWI)XK*`@0pDD?3pW>__!Gi3<6@P?3Cs zIx4>nCOJK*+-SFf__v_YpN2-b%09S=1E}A|p4eui`Qk0ZT?&OhWev1trsc)PRxO%mhgoNj(ESI1Y8DOR)^@Kwa0p zsD=N8^|1VhTxD#H{ct(9!+RKkDcjB8e$%lz^E>$zlw{|yzg|S`u+t7>o^=H#(ta4z z@HS>(!cOxmI32a~GpL398+|ZpmoW~*sMkQ{O6y`>5{-g(HXfA}3sBc)jcqT$a@0>- zFQEqb(LR5S%I?73W}G-wWSXMh8-u<$A2sfB^v0c7eE;`QP|_8mB5@i!;2qS8lRvT% zK|Rkyy}t+*iFKHY*RT)=#YeK=(BscThq9h$#h}e=>#q0@L9b&ZYie zRMJe{YeIPxwX;Xox}Tc%5l9j_>#!;QfGHTh&rH}In^B*O-S7)kq$=z;Khs_IbN+g; zoQ6(#12tfs17?6+RDBD+q~}-#n;$gEIsn^H--p@w1Z&~ILeoAE6|u7zgx{l%@Sbh= zPyEc>>qu0n8e&b%MqR&!SP{1ubV}G+%4gKL*K@`e{9HRYu=?Q9W5?zU9`9+>D!ose zj;%b|#n0+^(sOgN$K`m&kDctv9+o{~RGk8^7SpR292(Z3M47scJjo53G)OIYJbY%^ xGQEy{m^XUVxSWD1W7~y9{cp{aJ>mbx95KqylCz}zTVVx9W}m9R\n" "Language: fr\n" @@ -19,7 +19,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" -#: app/validators.py:49 +#: app/validators.py:50 msgid "" "Password must be at least 8 characters with uppercase, lowercase, and a " "number." @@ -27,97 +27,97 @@ msgstr "" "Le mot de passe doit compter au moins 8 caractères, dont une majuscule, " "une minuscule et un chiffre." -#: app/validators.py:67 +#: app/validators.py:68 msgid "Username must be 3-30 characters (letters, numbers, underscore, hyphen)." msgstr "" "Le nom d’utilisateur doit compter de 3 à 30 caractères (lettres, " "chiffres, tiret bas, trait d’union)." -#: app/validators.py:86 +#: app/validators.py:87 msgid "Invalid Discord username format." msgstr "Format de nom d’utilisateur Discord invalide." -#: app/validators.py:103 +#: app/validators.py:104 msgid "Discord User ID must be a 17-20 digit number." msgstr "L’identifiant Discord doit être un nombre de 17 à 20 chiffres." -#: app/validators.py:121 +#: app/validators.py:122 msgid "Invalid phone number format." msgstr "Format de numéro de téléphone invalide." -#: app/validators.py:163 +#: app/validators.py:164 msgid "Username is required." msgstr "Le nom d’utilisateur est obligatoire." -#: app/validators.py:167 +#: app/validators.py:168 msgid "Password is required." msgstr "Le mot de passe est obligatoire." -#: app/validators.py:189 app/validators.py:261 +#: app/validators.py:190 app/validators.py:262 msgid "Username must be 3-80 characters." msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." -#: app/validators.py:195 +#: app/validators.py:196 msgid "Email must be 120 characters or less." msgstr "L’adresse courriel ne doit pas dépasser 120 caractères." -#: app/validators.py:208 app/validators.py:276 app/validators.py:307 -#: app/validators.py:371 +#: app/validators.py:209 app/validators.py:277 app/validators.py:308 +#: app/validators.py:372 msgid "Full name is required." msgstr "Le nom complet est obligatoire." -#: app/validators.py:243 +#: app/validators.py:244 msgid "Passwords do not match." msgstr "Les mots de passe ne concordent pas." -#: app/validators.py:280 app/validators.py:315 +#: app/validators.py:281 app/validators.py:316 msgid "Invalid role selected." msgstr "Rôle sélectionné invalide." -#: app/validators.py:416 +#: app/validators.py:417 msgid "Player must be selected." msgstr "Vous devez choisir un joueur." -#: app/validators.py:419 +#: app/validators.py:420 msgid "Notes must be 2000 characters or less." msgstr "Les notes ne doivent pas dépasser 2000 caractères." -#: app/validators.py:438 +#: app/validators.py:439 msgid "Date must be in YYYY-MM-DD format." msgstr "La date doit être au format AAAA-MM-JJ." -#: app/validators.py:443 app/validators.py:470 +#: app/validators.py:444 app/validators.py:471 msgid "Start time must be in HH:MM format." msgstr "L’heure de début doit être au format HH:MM." -#: app/validators.py:447 +#: app/validators.py:448 msgid "End time must be in HH:MM format." msgstr "L’heure de fin doit être au format HH:MM." -#: app/validators.py:450 +#: app/validators.py:451 msgid "Points must be 2000 characters or less." msgstr "Les points ne doivent pas dépasser 2000 caractères." -#: app/validators.py:466 +#: app/validators.py:467 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." -#: app/routes/auth.py:186 app/routes/auth.py:322 app/routes/users.py:106 -#: app/routes/users.py:821 +#: app/routes/auth.py:224 app/routes/auth.py:374 app/routes/users/_shared.py:64 +#: app/routes/users/contracts.py:95 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s : %(msg)s" -#: app/routes/auth.py:203 +#: app/routes/auth.py:241 msgid "This account has been deactivated." msgstr "Ce compte a été désactivé." -#: app/routes/auth.py:238 +#: app/routes/auth.py:276 #, python-format msgid "Welcome back, %(username)s!" msgstr "Bon retour, %(username)s !" -#: app/routes/auth.py:268 +#: app/routes/auth.py:306 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -125,27 +125,27 @@ msgstr "" "Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, " "ou demandez de l’aide à un président." -#: app/routes/auth.py:303 -msgid "Incorrect CAPTCHA answer. Please try again." -msgstr "Réponse au CAPTCHA incorrecte. Veuillez réessayer." +#: app/routes/auth.py:363 +msgid "Your registration could not be processed. Please try again." +msgstr "Votre inscription n'a pas pu être traitée. Veuillez réessayer." -#: app/routes/auth.py:345 app/routes/users.py:436 +#: app/routes/auth.py:388 app/routes/users/accounts.py:308 msgid "Username already exists." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/auth.py:357 app/routes/users.py:440 +#: app/routes/auth.py:392 app/routes/users/accounts.py:312 msgid "Email already registered." msgstr "Cette adresse courriel est déjà enregistrée." -#: app/routes/auth.py:404 +#: app/routes/auth.py:436 msgid "Your account has been created! You can now log in." msgstr "Votre compte a été créé. Vous pouvez maintenant vous connecter." -#: app/routes/auth.py:430 +#: app/routes/auth.py:457 msgid "Discord OAuth2 is not configured." msgstr "La connexion Discord n’est pas configurée." -#: app/routes/auth.py:471 +#: app/routes/auth.py:498 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -153,422 +153,423 @@ msgstr "" "L’autorisation Discord n’a pas pu être vérifiée. Relancez la connexion " "depuis cette page." -#: app/routes/auth.py:480 +#: app/routes/auth.py:507 msgid "Discord authorization failed. No code received." msgstr "L’autorisation Discord a échoué : aucun code reçu." -#: app/routes/auth.py:504 +#: app/routes/auth.py:531 msgid "Failed to connect to Discord. Please try again." msgstr "Impossible de joindre Discord. Veuillez réessayer." -#: app/routes/auth.py:508 +#: app/routes/auth.py:535 msgid "Failed to obtain Discord access token." msgstr "Impossible d’obtenir le jeton d’accès Discord." -#: app/routes/auth.py:523 +#: app/routes/auth.py:550 msgid "Failed to fetch Discord user profile." msgstr "Impossible de récupérer le profil Discord." -#: app/routes/auth.py:570 +#: app/routes/auth.py:597 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Compte Discord connecté. Votre profil a été pré-rempli." -#: app/routes/auth.py:598 +#: app/routes/auth.py:625 msgid "You have been logged out." msgstr "Vous avez été déconnecté." -#: app/routes/evaluations.py:45 +#: app/routes/evaluations.py:46 msgid "You do not have permission to view evaluations." msgstr "Vous n’avez pas les droits pour consulter les évaluations." -#: app/routes/evaluations.py:135 +#: app/routes/evaluations.py:136 msgid "You do not have permission to evaluate players." msgstr "Vous n’avez pas les droits pour évaluer des joueurs." -#: app/routes/evaluations.py:140 app/routes/evaluations.py:263 +#: app/routes/evaluations.py:141 app/routes/evaluations.py:264 msgid "You do not have permission to evaluate players in this tryout." msgstr "Vous n’avez pas les droits pour évaluer des joueurs dans cette sélection." -#: app/routes/evaluations.py:151 +#: app/routes/evaluations.py:152 msgid "Player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/evaluations.py:156 +#: app/routes/evaluations.py:157 msgid "Can only evaluate players." msgstr "Seuls des joueurs peuvent être évalués." -#: app/routes/evaluations.py:208 +#: app/routes/evaluations.py:209 msgid "Evaluation updated!" msgstr "Évaluation mise à jour." -#: app/routes/evaluations.py:228 +#: app/routes/evaluations.py:229 msgid "Evaluation submitted successfully!" msgstr "Évaluation enregistrée." -#: app/routes/evaluations.py:258 app/routes/teams.py:268 -#: app/routes/teams.py:309 app/routes/teams.py:350 app/routes/teams.py:375 -#: app/routes/teams.py:400 app/routes/teams.py:435 app/routes/tryouts.py:472 -#: app/routes/tryouts.py:488 app/routes/tryouts.py:508 -#: app/routes/tryouts.py:547 app/routes/tryouts.py:583 -#: app/routes/tryouts.py:602 +#: app/routes/evaluations.py:259 app/routes/teams.py:270 +#: app/routes/teams.py:311 app/routes/teams.py:352 app/routes/teams.py:377 +#: app/routes/teams.py:402 app/routes/teams.py:437 app/routes/tryouts.py:505 +#: app/routes/tryouts.py:521 app/routes/tryouts.py:541 +#: app/routes/tryouts.py:580 app/routes/tryouts.py:616 +#: app/routes/tryouts.py:635 msgid "Permission denied." msgstr "Accès refusé." -#: app/routes/main.py:53 +#: app/routes/main.py:55 msgid "That language is not available." msgstr "Cette langue n’est pas disponible." -#: app/routes/matches.py:245 +#: app/routes/matches.py:307 msgid "You do not have permission to schedule matches for this tryout." msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette sélection." -#: app/routes/matches.py:249 app/routes/matches.py:422 +#: app/routes/matches.py:311 app/routes/matches.py:473 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "" "Cette sélection est terminée. Les matchs ne peuvent plus être créés ni " "modifiés." -#: app/routes/matches.py:270 +#: app/routes/matches.py:332 msgid "Start time is required. Please select a time slot." msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." -#: app/routes/matches.py:282 app/routes/matches.py:445 -#: app/routes/team_matches.py:151 app/routes/team_matches.py:255 +#: app/routes/matches.py:344 app/routes/matches.py:496 +#: app/routes/team_matches.py:153 app/routes/team_matches.py:247 msgid "Invalid date format." msgstr "Format de date invalide." -#: app/routes/matches.py:302 app/routes/team_matches.py:172 +#: app/routes/matches.py:364 app/routes/team_matches.py:174 msgid "Invalid time format." msgstr "Format d’heure invalide." -#: app/routes/matches.py:398 +#: app/routes/matches.py:449 msgid "Match scheduled successfully!" msgstr "Match planifié." -#: app/routes/matches.py:418 app/routes/team_matches.py:242 +#: app/routes/matches.py:469 app/routes/team_matches.py:234 msgid "You do not have permission to edit this match." msgstr "Vous n’avez pas les droits pour modifier ce match." -#: app/routes/matches.py:456 +#: app/routes/matches.py:507 msgid "Start time is required." msgstr "L’heure de début est obligatoire." -#: app/routes/matches.py:578 app/routes/team_matches.py:284 +#: app/routes/matches.py:616 app/routes/team_matches.py:276 msgid "Match updated successfully!" msgstr "Match mis à jour." -#: app/routes/matches.py:631 app/routes/team_matches.py:299 +#: app/routes/matches.py:669 app/routes/team_matches.py:291 msgid "You do not have permission to delete this match." msgstr "Vous n’avez pas les droits pour supprimer ce match." -#: app/routes/matches.py:634 +#: app/routes/matches.py:672 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "Cette sélection est terminée. Les matchs ne peuvent plus être supprimés." -#: app/routes/matches.py:647 app/routes/team_matches.py:303 +#: app/routes/matches.py:685 app/routes/team_matches.py:295 msgid "Match deleted successfully." msgstr "Match supprimé." -#: app/routes/team_matches.py:98 +#: app/routes/team_matches.py:100 msgid "You do not have permission to schedule matches for this team." msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette équipe." -#: app/routes/team_matches.py:140 +#: app/routes/team_matches.py:142 msgid "Date is required." msgstr "La date est obligatoire." -#: app/routes/team_matches.py:228 +#: app/routes/team_matches.py:220 #, python-format msgid "Team match \"%(title)s\" scheduled successfully!" msgstr "Match d’équipe « %(title)s » planifié." -#: app/routes/team_matches.py:267 +#: app/routes/team_matches.py:259 msgid "Invalid start time format." msgstr "Format d’heure de début invalide." -#: app/routes/team_matches.py:275 +#: app/routes/team_matches.py:267 msgid "Invalid end time format." msgstr "Format d’heure de fin invalide." -#: app/routes/teams.py:38 +#: app/routes/teams.py:40 msgid "Use My Team(s) to view your teams." msgstr "Utilisez « Mon ou mes équipes » pour consulter vos équipes." -#: app/routes/teams.py:41 +#: app/routes/teams.py:43 msgid "You do not have permission to view teams." msgstr "Vous n’avez pas les droits pour consulter les équipes." -#: app/routes/teams.py:68 +#: app/routes/teams.py:70 msgid "This page is for players." msgstr "Cette page est réservée aux joueurs." -#: app/routes/teams.py:121 +#: app/routes/teams.py:123 msgid "You do not have permission to create teams." msgstr "Vous n’avez pas les droits pour créer une équipe." -#: app/routes/teams.py:129 app/routes/teams.py:174 +#: app/routes/teams.py:131 app/routes/teams.py:176 msgid "Team name is required." msgstr "Le nom de l’équipe est obligatoire." -#: app/routes/teams.py:134 app/routes/teams.py:179 +#: app/routes/teams.py:136 app/routes/teams.py:181 #, python-format msgid "Team \"%(name)s\" already exists." msgstr "L’équipe « %(name)s » existe déjà." -#: app/routes/teams.py:156 +#: app/routes/teams.py:158 #, python-format msgid "Team \"%(name)s\" created successfully!" msgstr "Équipe « %(name)s » créée." -#: app/routes/teams.py:166 +#: app/routes/teams.py:168 msgid "You do not have permission to edit this team." msgstr "Vous n’avez pas les droits pour modifier cette équipe." -#: app/routes/teams.py:217 +#: app/routes/teams.py:219 #, python-format msgid "Team \"%(name)s\" updated successfully!" msgstr "Équipe « %(name)s » mise à jour." -#: app/routes/teams.py:226 +#: app/routes/teams.py:228 msgid "You do not have permission to delete teams." msgstr "Vous n’avez pas les droits pour supprimer une équipe." -#: app/routes/teams.py:258 +#: app/routes/teams.py:260 #, python-format msgid "Team \"%(name)s\" deleted successfully." msgstr "Équipe « %(name)s » supprimée." -#: app/routes/teams.py:273 +#: app/routes/teams.py:275 msgid "Please select a coach." msgstr "Veuillez choisir un coach." -#: app/routes/teams.py:278 +#: app/routes/teams.py:280 msgid "Only coaches can be assigned as coach." msgstr "Seuls les coachs peuvent être assignés comme coach." -#: app/routes/teams.py:284 +#: app/routes/teams.py:286 #, python-format msgid "%(username)s is already a coach of %(name)s." msgstr "%(username)s est déjà coach de %(name)s." -#: app/routes/teams.py:297 +#: app/routes/teams.py:299 #, python-format msgid "%(username)s added as coach of %(name)s." msgstr "%(username)s a été ajouté comme coach de %(name)s." -#: app/routes/teams.py:314 +#: app/routes/teams.py:316 msgid "Please select a manager." msgstr "Veuillez choisir un gérant." -#: app/routes/teams.py:319 +#: app/routes/teams.py:321 msgid "Only managers can be assigned as manager." msgstr "Seuls les gérants peuvent être assignés comme gérant." -#: app/routes/teams.py:325 +#: app/routes/teams.py:327 #, python-format msgid "%(username)s is already a manager of %(name)s." msgstr "%(username)s est déjà gérant de %(name)s." -#: app/routes/teams.py:338 +#: app/routes/teams.py:340 #, python-format msgid "%(username)s added as manager of %(name)s." msgstr "%(username)s a été ajouté comme gérant de %(name)s." -#: app/routes/teams.py:365 +#: app/routes/teams.py:367 #, python-format msgid "Coach removed from %(name)s." msgstr "Coach retiré de %(name)s." -#: app/routes/teams.py:390 +#: app/routes/teams.py:392 #, python-format msgid "Manager removed from %(name)s." msgstr "Gérant retiré de %(name)s." -#: app/routes/teams.py:406 app/routes/tryouts.py:512 app/routes/tryouts.py:613 +#: app/routes/teams.py:408 app/routes/tryouts.py:545 app/routes/tryouts.py:646 msgid "Please select a player." msgstr "Veuillez choisir un joueur." -#: app/routes/teams.py:411 +#: app/routes/teams.py:413 msgid "Can only assign players to teams." msgstr "Seuls des joueurs peuvent être assignés à une équipe." -#: app/routes/teams.py:417 +#: app/routes/teams.py:419 #, python-format msgid "%(username)s is already on %(name)s." msgstr "%(username)s fait déjà partie de %(name)s." -#: app/routes/teams.py:425 +#: app/routes/teams.py:427 #, python-format msgid "%(username)s added to %(name)s!" msgstr "%(username)s a été ajouté à %(name)s." -#: app/routes/teams.py:442 app/routes/teams.py:515 +#: app/routes/teams.py:444 app/routes/teams.py:517 #, python-format msgid "%(username)s is not on %(name)s." msgstr "%(username)s ne fait pas partie de %(name)s." -#: app/routes/teams.py:450 +#: app/routes/teams.py:452 #, python-format msgid "%(username)s removed from %(name)s." msgstr "%(username)s a été retiré de %(name)s." -#: app/routes/teams.py:486 app/routes/teams.py:504 +#: app/routes/teams.py:488 app/routes/teams.py:506 msgid "You do not have permission to add notes to this team." msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe." -#: app/routes/teams.py:494 +#: app/routes/teams.py:496 msgid "Team notes added successfully!" msgstr "Notes d’équipe ajoutées." -#: app/routes/teams.py:509 app/routes/users.py:1530 app/routes/users.py:1573 +#: app/routes/teams.py:511 app/routes/users/notes.py:207 +#: app/routes/users/notes.py:250 msgid "Can only add notes for players." msgstr "Il n’est possible d’ajouter des notes que pour des joueurs." -#: app/routes/teams.py:525 +#: app/routes/teams.py:527 #, python-format msgid "Note added for %(username)s!" msgstr "Note ajoutée pour %(username)s." -#: app/routes/tryouts.py:56 +#: app/routes/tryouts.py:77 msgid "You do not have permission to create tryouts." msgstr "Vous n’avez pas les droits pour créer une sélection." -#: app/routes/tryouts.py:82 app/routes/tryouts.py:189 +#: app/routes/tryouts.py:103 app/routes/tryouts.py:210 msgid "Invalid start date format." msgstr "Format de date de début invalide." -#: app/routes/tryouts.py:97 app/routes/tryouts.py:204 +#: app/routes/tryouts.py:118 app/routes/tryouts.py:225 msgid "End date cannot be before start date." msgstr "La date de fin ne peut pas précéder la date de début." -#: app/routes/tryouts.py:107 app/routes/tryouts.py:214 +#: app/routes/tryouts.py:128 app/routes/tryouts.py:235 msgid "Invalid end date format." msgstr "Format de date de fin invalide." -#: app/routes/tryouts.py:139 +#: app/routes/tryouts.py:160 msgid "Tryout created successfully!" msgstr "Sélection créée." -#: app/routes/tryouts.py:159 +#: app/routes/tryouts.py:180 msgid "You do not have permission to edit this tryout." msgstr "Vous n’avez pas les droits pour modifier cette sélection." -#: app/routes/tryouts.py:163 +#: app/routes/tryouts.py:184 msgid "This tryout has ended and can no longer be modified." msgstr "Cette sélection est terminée et ne peut plus être modifiée." -#: app/routes/tryouts.py:242 +#: app/routes/tryouts.py:263 msgid "Tryout updated successfully!" msgstr "Sélection mise à jour." -#: app/routes/tryouts.py:289 +#: app/routes/tryouts.py:310 msgid "You do not have permission to view this tryout." msgstr "Vous n’avez pas les droits pour consulter cette sélection." -#: app/routes/tryouts.py:439 +#: app/routes/tryouts.py:472 msgid "Only players can register for tryouts." msgstr "Seuls les joueurs peuvent s’inscrire à une sélection." -#: app/routes/tryouts.py:443 +#: app/routes/tryouts.py:476 msgid "This tryout is not accepting registrations." msgstr "Cette sélection n’accepte pas d’inscriptions." -#: app/routes/tryouts.py:450 +#: app/routes/tryouts.py:483 msgid "You are already registered for this tryout." msgstr "Vous êtes déjà inscrit à cette sélection." -#: app/routes/tryouts.py:456 app/routes/tryouts.py:531 +#: app/routes/tryouts.py:489 app/routes/tryouts.py:564 msgid "This tryout is full." msgstr "Cette sélection est complète." -#: app/routes/tryouts.py:462 +#: app/routes/tryouts.py:495 msgid "Successfully registered for tryout!" msgstr "Inscription à la sélection réussie." -#: app/routes/tryouts.py:478 +#: app/routes/tryouts.py:511 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Statut de la sélection mis à jour : %(new_status)s." -#: app/routes/tryouts.py:498 +#: app/routes/tryouts.py:531 msgid "Registration status updated." msgstr "Statut d’inscription mis à jour." -#: app/routes/tryouts.py:517 +#: app/routes/tryouts.py:550 msgid "Can only register players." msgstr "Seuls des joueurs peuvent être inscrits." -#: app/routes/tryouts.py:523 +#: app/routes/tryouts.py:556 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s est déjà inscrit à cette sélection." -#: app/routes/tryouts.py:537 +#: app/routes/tryouts.py:570 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s est inscrit à la sélection." -#: app/routes/tryouts.py:573 +#: app/routes/tryouts.py:606 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s a été retiré de la sélection." -#: app/routes/tryouts.py:591 +#: app/routes/tryouts.py:624 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Équipe « %(team_name)s » créée." -#: app/routes/tryouts.py:622 +#: app/routes/tryouts.py:655 msgid "That player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/tryouts.py:628 +#: app/routes/tryouts.py:661 msgid "Player is already on this team." msgstr "Ce joueur est déjà dans cette équipe." -#: app/routes/tryouts.py:633 +#: app/routes/tryouts.py:666 msgid "Player added to team!" msgstr "Joueur ajouté à l’équipe." -#: app/routes/tryouts.py:643 +#: app/routes/tryouts.py:676 msgid "You do not have permission to delete this tryout." msgstr "Vous n’avez pas les droits pour supprimer cette sélection." -#: app/routes/tryouts.py:679 +#: app/routes/tryouts.py:712 msgid "Tryout deleted successfully." msgstr "Sélection supprimée." -#: app/routes/users.py:83 +#: app/routes/users/_shared.py:46 msgid "No file selected." msgstr "Aucun fichier sélectionné." -#: app/routes/users.py:87 +#: app/routes/users/_shared.py:50 msgid "Only PDF files are allowed for contracts." msgstr "Seuls les fichiers PDF sont acceptés pour les contrats." -#: app/routes/users.py:92 +#: app/routes/users/_shared.py:55 msgid "That file is not a PDF, whatever its name says." msgstr "Ce fichier n’est pas un PDF, quel que soit son nom." -#: app/routes/users.py:181 +#: app/routes/users/accounts.py:53 msgid "Only the president can manage users." msgstr "Seul le président peut gérer les utilisateurs." -#: app/routes/users.py:193 +#: app/routes/users/accounts.py:65 msgid "Only the president can edit users." msgstr "Seul le président peut modifier des utilisateurs." -#: app/routes/users.py:234 +#: app/routes/users/accounts.py:106 msgid "Email already in use by another account." msgstr "Cette adresse courriel est déjà utilisée par un autre compte." -#: app/routes/users.py:245 +#: app/routes/users/accounts.py:117 msgid "You cannot change your own role. Ask another president to do it." msgstr "" "Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre " "président de le faire." -#: app/routes/users.py:258 +#: app/routes/users/accounts.py:130 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -576,177 +577,178 @@ msgstr "" "C’est le dernier président actif. Promouvez un autre compte avant de " "modifier celui-ci." -#: app/routes/users.py:337 +#: app/routes/users/accounts.py:209 #, python-format msgid "User %(username)s updated successfully!" msgstr "Utilisateur %(username)s mis à jour." -#: app/routes/users.py:358 +#: app/routes/users/accounts.py:230 msgid "Only the president can delete users." msgstr "Seul le président peut supprimer des utilisateurs." -#: app/routes/users.py:362 +#: app/routes/users/accounts.py:234 msgid "You cannot delete your own account." msgstr "Vous ne pouvez pas supprimer votre propre compte." -#: app/routes/users.py:405 +#: app/routes/users/accounts.py:277 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "L’utilisateur %(deleted_username)s a été supprimé." -#: app/routes/users.py:416 +#: app/routes/users/accounts.py:288 msgid "Only the president can create users." msgstr "Seul le président peut créer des utilisateurs." -#: app/routes/users.py:464 +#: app/routes/users/accounts.py:336 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." -#: app/routes/users.py:534 -msgid "Username already taken." -msgstr "Ce nom d’utilisateur est déjà pris." +#: app/routes/users/availability.py:179 +msgid "Only coaches can manage availability." +msgstr "Seuls les coachs peuvent gérer leurs disponibilités." -#: app/routes/users.py:544 -msgid "Email already in use." -msgstr "Cette adresse courriel est déjà utilisée." - -#: app/routes/users.py:574 -msgid "Profile updated successfully!" -msgstr "Profil mis à jour." - -#: app/routes/users.py:809 +#: app/routes/users/contracts.py:83 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Seuls les présidents, gérants et coachs peuvent téléverser un contrat." -#: app/routes/users.py:828 +#: app/routes/users/contracts.py:102 msgid "You do not have permission to upload a contract for this player." msgstr "Vous n’avez pas les droits pour téléverser un contrat pour ce joueur." -#: app/routes/users.py:869 +#: app/routes/users/contracts.py:143 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contrat téléversé pour %(username)s." -#: app/routes/users.py:883 +#: app/routes/users/contracts.py:157 msgid "Only the player can upload their signed contract." msgstr "Seul le joueur peut téléverser son contrat signé." -#: app/routes/users.py:902 +#: app/routes/users/contracts.py:176 msgid "Signed contract uploaded successfully!" msgstr "Contrat signé téléversé." -#: app/routes/users.py:912 app/routes/users.py:925 +#: app/routes/users/contracts.py:186 app/routes/users/contracts.py:199 msgid "You do not have permission to download this contract." msgstr "Vous n’avez pas les droits pour télécharger ce contrat." -#: app/routes/users.py:928 +#: app/routes/users/contracts.py:202 msgid "No signed contract available." msgstr "Aucun contrat signé disponible." -#: app/routes/users.py:1034 -msgid "Only players can request One on One sessions." -msgstr "Seuls les joueurs peuvent demander une rencontre individuelle." - -#: app/routes/users.py:1047 -msgid "You do not have a coach assigned to your team." -msgstr "Aucun coach n’est assigné à votre équipe." - -#: app/routes/users.py:1082 -msgid "Cannot request One on One - no coach assigned." -msgstr "Impossible de demander une rencontre : aucun coach assigné." - -#: app/routes/users.py:1090 -msgid "Invalid date or time format." -msgstr "Format de date ou d’heure invalide." - -#: app/routes/users.py:1104 -msgid "The requested time is not within the coach's availability." -msgstr "L’horaire demandé ne correspond à aucune disponibilité du coach." - -#: app/routes/users.py:1132 -msgid "Your One on One request has been submitted!" -msgstr "Votre demande de rencontre a été envoyée." - -#: app/routes/users.py:1176 -msgid "Only coaches can accept One on One requests." -msgstr "Seuls les coachs peuvent accepter une demande de rencontre." - -#: app/routes/users.py:1182 app/routes/users.py:1232 -msgid "This request is not for you." -msgstr "Cette demande ne vous est pas destinée." - -#: app/routes/users.py:1186 app/routes/users.py:1236 -msgid "This request has already been processed." -msgstr "Cette demande a déjà été traitée." - -#: app/routes/users.py:1213 -#, python-format -msgid "One on One request from %(player)s has been approved!" -msgstr "La demande de rencontre de %(player)s a été approuvée." - -#: app/routes/users.py:1226 -msgid "Only coaches can reject One on One requests." -msgstr "Seuls les coachs peuvent refuser une demande de rencontre." - -#: app/routes/users.py:1268 -#, python-format -msgid "One on One request from %(player)s has been rejected." -msgstr "La demande de rencontre de %(player)s a été refusée." - -#: app/routes/users.py:1286 +#: app/routes/users/notes.py:34 msgid "This page is for players only." msgstr "Cette page est réservée aux joueurs." -#: app/routes/users.py:1328 -msgid "Only coaches can manage availability." -msgstr "Seuls les coachs peuvent gérer leurs disponibilités." - -#: app/routes/users.py:1394 +#: app/routes/users/notes.py:71 msgid "Only coaches can access the notes dashboard." msgstr "Seuls les coachs ont accès au tableau des notes." -#: app/routes/users.py:1484 +#: app/routes/users/notes.py:161 msgid "Only coaches can manage team notes." msgstr "Seuls les coachs peuvent gérer les notes d’équipe." -#: app/routes/users.py:1490 +#: app/routes/users/notes.py:167 msgid "You are not assigned to a team." msgstr "Vous n’êtes assigné à aucune équipe." -#: app/routes/users.py:1503 +#: app/routes/users/notes.py:180 msgid "Team notes saved successfully!" msgstr "Notes d’équipe enregistrées." -#: app/routes/users.py:1518 +#: app/routes/users/notes.py:195 msgid "Only coaches can manage personal notes." msgstr "Seuls les coachs peuvent gérer les notes personnelles." -#: app/routes/users.py:1525 app/routes/users.py:1568 app/routes/users.py:1619 -#: app/routes/users.py:1673 +#: app/routes/users/notes.py:202 app/routes/users/notes.py:245 +#: app/routes/users/notes.py:296 app/routes/users/notes.py:350 msgid "Player and content are required." msgstr "Le joueur et le contenu sont obligatoires." -#: app/routes/users.py:1534 app/routes/users.py:1577 app/routes/users.py:1623 -#: app/routes/users.py:1677 +#: app/routes/users/notes.py:211 app/routes/users/notes.py:254 +#: app/routes/users/notes.py:300 app/routes/users/notes.py:354 msgid "You can only write notes about players you work with." msgstr "" "Vous ne pouvez écrire des notes que sur les joueurs avec qui vous " "travaillez." -#: app/routes/users.py:1544 app/routes/users.py:1590 +#: app/routes/users/notes.py:221 app/routes/users/notes.py:267 #, python-format msgid "Note added for %(username)s." msgstr "Note ajoutée pour %(username)s." -#: app/routes/users.py:1558 app/routes/users.py:1604 app/routes/users.py:1657 +#: app/routes/users/notes.py:235 app/routes/users/notes.py:281 +#: app/routes/users/notes.py:334 msgid "Only coaches can add personal notes." msgstr "Seuls les coachs peuvent ajouter des notes personnelles." -#: app/routes/users.py:1634 app/routes/users.py:1688 +#: app/routes/users/notes.py:311 app/routes/users/notes.py:365 msgid "Note added successfully." msgstr "Note ajoutée." +#: app/routes/users/one_on_one.py:20 +msgid "Only players can request One on One sessions." +msgstr "Seuls les joueurs peuvent demander une rencontre individuelle." + +#: app/routes/users/one_on_one.py:33 +msgid "You do not have a coach assigned to your team." +msgstr "Aucun coach n’est assigné à votre équipe." + +#: app/routes/users/one_on_one.py:68 +msgid "Cannot request One on One - no coach assigned." +msgstr "Impossible de demander une rencontre : aucun coach assigné." + +#: app/routes/users/one_on_one.py:76 +msgid "Invalid date or time format." +msgstr "Format de date ou d’heure invalide." + +#: app/routes/users/one_on_one.py:90 +msgid "The requested time is not within the coach's availability." +msgstr "L’horaire demandé ne correspond à aucune disponibilité du coach." + +#: app/routes/users/one_on_one.py:118 +msgid "Your One on One request has been submitted!" +msgstr "Votre demande de rencontre a été envoyée." + +#: app/routes/users/one_on_one.py:163 +msgid "Only coaches can accept One on One requests." +msgstr "Seuls les coachs peuvent accepter une demande de rencontre." + +#: app/routes/users/one_on_one.py:169 app/routes/users/one_on_one.py:219 +msgid "This request is not for you." +msgstr "Cette demande ne vous est pas destinée." + +#: app/routes/users/one_on_one.py:173 app/routes/users/one_on_one.py:223 +msgid "This request has already been processed." +msgstr "Cette demande a déjà été traitée." + +#: app/routes/users/one_on_one.py:200 +#, python-format +msgid "One on One request from %(player)s has been approved!" +msgstr "La demande de rencontre de %(player)s a été approuvée." + +#: app/routes/users/one_on_one.py:213 +msgid "Only coaches can reject One on One requests." +msgstr "Seuls les coachs peuvent refuser une demande de rencontre." + +#: app/routes/users/one_on_one.py:255 +#, python-format +msgid "One on One request from %(player)s has been rejected." +msgstr "La demande de rencontre de %(player)s a été refusée." + +#: app/routes/users/profile.py:83 +msgid "Username already taken." +msgstr "Ce nom d’utilisateur est déjà pris." + +#: app/routes/users/profile.py:93 +msgid "Email already in use." +msgstr "Cette adresse courriel est déjà utilisée." + +#: app/routes/users/profile.py:123 +msgid "Profile updated successfully!" +msgstr "Profil mis à jour." + #: app/templates/errors/400.html:2 msgid "400 Bad Request" msgstr "400 Requête incorrecte" @@ -2387,11 +2389,7 @@ msgstr "Confirmer le mot de passe" msgid "Confirm your password" msgstr "Confirmez votre mot de passe" -#: app/templates/pages/register.html:151 -msgid "Answer" -msgstr "Réponse" - -#: app/templates/pages/register.html:154 +#: app/templates/pages/register.html:159 msgid "Create Account" msgstr "Créer le compte" @@ -2900,3 +2898,9 @@ msgstr "Voir le profil" #~ "%(remaining)s tentative(s) avant le " #~ "verrouillage." +#~ msgid "Incorrect CAPTCHA answer. Please try again." +#~ msgstr "Réponse au CAPTCHA incorrecte. Veuillez réessayer." + +#~ msgid "Answer" +#~ msgstr "Réponse" + diff --git a/tests/test_registration_screening.py b/tests/test_registration_screening.py new file mode 100644 index 0000000..472d9e3 --- /dev/null +++ b/tests/test_registration_screening.py @@ -0,0 +1,217 @@ +"""What now stands between a robot and a new account (SEC-AUTH-008). + +The arithmetic CAPTCHA it replaces had nineteen possible answers and could +be solved by reading the question as a string. It stopped nothing, cost every +human a step, and — being counted as a protection — was worse than nothing. + +Two checks took its place: a honeypot input, and a floor on how fast the +form can come back. Both are invisible to a person filling in the form. Both +are honest about their ceiling: they stop commodity spam, not somebody who +reads the page. + +The tests that matter most here are the ones asserting a *legitimate* +sign-up still works. A screening rule that quietly refuses real people is a +worse outcome than the CAPTCHA was. +""" + +import time + +from app.routes.auth import ( + MIN_REGISTRATION_SECONDS, + REGISTRATION_HONEYPOT_FIELD, + REGISTRATION_ISSUED_KEY, +) + +FORM = { + 'username': 'brandnew', + 'email': 'brandnew@example.test', + 'password': 'Password123', + 'confirm_password': 'Password123', + 'full_name': 'Brand New', +} + + +def _issued_long_ago(client, seconds_ago=None): + """Pretend the form was handed out a while back. + + Backdated rather than slept through: waiting three real seconds per test + would add a minute to the suite for nothing. + """ + if seconds_ago is None: + seconds_ago = MIN_REGISTRATION_SECONDS + 1 + with client.session_transaction() as session: + session[REGISTRATION_ISSUED_KEY] = time.time() - seconds_ago + + +def _account_exists(app, username='brandnew'): + from app.models import User + + with app.app_context(): + return User.query.filter_by(username=username).first() is not None + + +class TestLegitimateSignUp: + def test_a_person_filling_the_form_gets_an_account(self, app, client): + client.get('/auth/register') + _issued_long_ago(client) + + client.post('/auth/register', data=dict(FORM), follow_redirects=True) + + assert _account_exists(app) + + def test_an_empty_honeypot_is_not_a_refusal(self, app, client): + """A browser submits the hidden input as an empty string, not absent.""" + _issued_long_ago(client) + + client.post( + '/auth/register', + data=dict(FORM, **{REGISTRATION_HONEYPOT_FIELD: ''}), + follow_redirects=True, + ) + + assert _account_exists(app) + + def test_correcting_a_typo_does_not_restart_the_clock(self, app, client): + """The dwell timer must survive a failed attempt. + + Reissuing it on every re-render would refuse the second submission of + anyone who fixes a mistake quickly — a rule that fires on real people + and not on robots, which is the wrong way round. + """ + client.get('/auth/register') + _issued_long_ago(client) + + # First attempt fails validation: passwords do not match. + client.post( + '/auth/register', + data=dict(FORM, confirm_password='Different123'), + follow_redirects=True, + ) + # Corrected and sent straight back, well inside the dwell floor. + client.post('/auth/register', data=dict(FORM), follow_redirects=True) + + assert _account_exists(app) + + +class TestScreening: + def test_a_filled_honeypot_is_refused(self, app, client): + _issued_long_ago(client) + + client.post( + '/auth/register', + data=dict(FORM, **{REGISTRATION_HONEYPOT_FIELD: 'http://spam.example'}), + follow_redirects=True, + ) + + assert not _account_exists(app) + + def test_a_form_returned_instantly_is_refused(self, app, client): + client.get('/auth/register') + + client.post('/auth/register', data=dict(FORM), follow_redirects=True) + + assert not _account_exists(app) + + def test_a_post_that_never_fetched_the_form_is_refused(self, app, client): + """The strongest signal available: no form was ever issued.""" + client.post('/auth/register', data=dict(FORM), follow_redirects=True) + + assert not _account_exists(app) + + def test_the_two_rules_are_indistinguishable_to_the_sender(self, app, client): + """Naming the rule tells whoever tripped it how to avoid it. + + Both refusals must read identically from outside. The log, which the + sender cannot see, is where they are told apart. + + One client per scenario, on purpose: the dwell stamp lives in the + session and is deliberately kept across a refusal, so reusing a single + client would carry the first scenario's backdated stamp into the + second and the "too fast" case would never fire. + """ + _issued_long_ago(client) + tripped_honeypot = client.post( + '/auth/register', + data=dict(FORM, **{REGISTRATION_HONEYPOT_FIELD: 'spam'}), + follow_redirects=True, + ).get_data(as_text=True) + + fresh = app.test_client() + fresh.get('/auth/register') + too_fast = fresh.post('/auth/register', data=dict(FORM), follow_redirects=True).get_data( + as_text=True + ) + + def flashes(page): + return [line for line in page.splitlines() if 'alert-danger' in line] + + assert flashes(tripped_honeypot) == flashes(too_fast) + assert flashes(tripped_honeypot), 'no message at all is not the same as a uniform one' + + +class TestTheFormItself: + def test_no_arithmetic_question_is_asked(self, client): + page = client.get('/auth/register').get_data(as_text=True) + + assert 'captcha' not in page.lower() + + def test_the_honeypot_is_hidden_from_assistive_technology(self, client): + """An input a screen reader announces is a trap for a person. + + aria-hidden, tabindex=-1 and display:none all have to hold. The CSS + rule lives in the stylesheet rather than in a style attribute so that + it survives a future tightening of style-src. + """ + page = client.get('/auth/register').get_data(as_text=True) + + assert 'class="honeypot" aria-hidden="true"' in page + assert f'name="{REGISTRATION_HONEYPOT_FIELD}" tabindex="-1"' in page + + def test_the_stylesheet_actually_hides_it(self, app): + import os + + with open(os.path.join(app.static_folder, 'css', 'style.css'), encoding='utf-8') as handle: + css = handle.read() + + block = css.split('.honeypot')[-1] + assert 'display: none' in block.split('}')[0] + + +class TestRefusalIsVisible: + def test_a_refusal_is_written_to_the_audit_log(self, app, client, monkeypatch): + """Sign-up abuse leaves a trace or it is not happening, as far as + anyone can tell.""" + recorded = [] + from app.routes import auth as auth_module + + monkeypatch.setattr( + auth_module, + 'log_auth_event', + lambda event, **fields: recorded.append((event, fields)), + ) + _issued_long_ago(client) + + client.post( + '/auth/register', + data=dict(FORM, **{REGISTRATION_HONEYPOT_FIELD: 'spam'}), + follow_redirects=True, + ) + + assert recorded, 'the double was never called — the test would pass on nothing' + assert recorded[0][0] == 'account.registration_refused' + assert recorded[0][1]['reason'] == 'honeypot' + + def test_the_reason_distinguishes_the_two_rules(self, app, client, monkeypatch): + recorded = [] + from app.routes import auth as auth_module + + monkeypatch.setattr( + auth_module, + 'log_auth_event', + lambda event, **fields: recorded.append((event, fields)), + ) + client.get('/auth/register') + + client.post('/auth/register', data=dict(FORM), follow_redirects=True) + + assert recorded and recorded[0][1]['reason'] == 'too-fast' diff --git a/tests/test_transactions.py b/tests/test_transactions.py index 921b1cb..9d7b84a 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -19,6 +19,8 @@ These tests state the guarantee, so that removing the rollback or reintroducing a mid-operation commit fails loudly. """ +import time + import pytest from app.models import User, UserGamertag @@ -78,9 +80,14 @@ class TestRegistrationIsOneOperation: } def _submit(self, client, app, **overrides): + # Registration is screened for robots (SEC-AUTH-008): the form has to + # have been issued, and long enough ago. Backdated here rather than + # slept through, so the suite does not pay three seconds per call. + from app.routes.auth import MIN_REGISTRATION_SECONDS, REGISTRATION_ISSUED_KEY + with client.session_transaction() as session: - session['captcha_answer'] = 4 - payload = dict(self.FORM, captcha_answer='4') + session[REGISTRATION_ISSUED_KEY] = time.time() - MIN_REGISTRATION_SECONDS - 1 + payload = dict(self.FORM) payload.update(overrides) return client.post('/auth/register', data=payload, follow_redirects=True) From 0308eb9eefb171ccd6cd3601b608b93106be7806 Mon Sep 17 00:00:00 2001 From: GGThed Date: Tue, 11 Aug 2026 13:09:00 -0400 Subject: [PATCH 45/75] refactor(validation): un schema a la frontiere des matchs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCH-005, premiere moitie. matches.py et team_matches.py lisaient une quarantaine de champs sur request.form a la main et les croyaient tous. Ce que ca produisait n etait pas bruyant : - edit_match attrapait une heure invalide et faisait start_time = None, puis annoncait que le match etait mis a jour. Le match perdait son heure et le calendrier l affichait a minuit ; - match_type etait accepte tel quel. Une valeur inconnue creait un match auquel aucun joueur n etait rattache, sans un mot ; - une fin avant le debut etait enregistree telle quelle ; - title est NOT NULL dans le modele et n etait pas verifie dans la route, donc un titre vide etait un 500 ; - 'a,b' dans la selection de joueurs arrivait sur int() sans garde. app/forms.py rassemble les deux fonctions de frontiere, qui vivaient dans users/_shared.py parce que c est la qu elles avaient d abord servi. Elles y restent re-exportees, donc aucun des trente appels n a bouge. Le mixin des schemas lit desormais un champ vide comme un champ absent. C est ce qui rendait ces formulaires invalidables : un formulaire HTML envoie tout ce qu il affiche, donc une date optionnelle non remplie arrive comme '' et non comme rien. Seuls les champs declares optionnels sont concernes ; un champ requis laisse vide doit toujours echouer. Deux duplications absorbees au passage, toutes deux nommees par l audit : la boucle de creation des participants, ecrite deux fois et deja divergee — la copie de edit_match gardait ses identifiants en chaines et appelait int() une ligne plus loin — et le contexte de re-affichage du formulaire, dont les versions courtes faisaient mourir un refus dans tojson sur un Undefined : un message de validation devenait un 500. Limite connue et consignee : le formulaire revient rempli avec les valeurs enregistrees, pas avec la saisie refusee. Reafficher la soumission demande de toucher aux gabarits, c est un autre changement. 19 tests neufs sur ces routes, qui n en avaient aucun. 447 au total. --- app/forms.py | 63 ++++++ app/routes/matches.py | 384 ++++++++++++--------------------- app/routes/team_matches.py | 129 ++++------- app/routes/users/_shared.py | 35 +-- app/validators.py | 194 ++++++++++++++++- tests/test_match_scheduling.py | 322 +++++++++++++++++++++++++++ 6 files changed, 769 insertions(+), 358 deletions(-) create mode 100644 app/forms.py create mode 100644 tests/test_match_scheduling.py diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..4a435e8 --- /dev/null +++ b/app/forms.py @@ -0,0 +1,63 @@ +"""The boundary between an HTTP form and a validated payload (ARCH-005). + +Every POST in this application arrives as a `werkzeug.MultiDict` of strings. +Turning that into typed, checked values was done inline, differently, in each +route: `int(x) if x else None` here, `datetime.strptime` inside a bare `try` +there, and in several places not at all. The failures that produced were not +loud ones — a bad time silently became `None` and the page said the match had +been updated. + +Two functions here, one schema module next to them (`app.validators`): + + payload = form_payload(list_fields=('player_ids',)) + try: + data = MatchSchema().load(payload) + except ValidationError as err: + flash_validation_errors(err) + return _rerender() + +Both were originally inside `app/routes/users/_shared.py`, which is where +they were first needed. They are re-exported from there so that nothing had +to be renamed when the match and tryout routes started using them too. +""" + +from flask import flash, request +from flask_babel import gettext as _ + + +def flash_validation_errors(err): + """Surface marshmallow errors, one flash per problem. + + The uniform reporting half of ARCH-005: before this, a bad date flashed + 'Invalid date format.' from one route, redirected from another, and was + silently dropped by a third. + """ + for field, messages in err.messages.items(): + for msg in messages: + flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger') + + +def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('password',)): + """Turn the multi-valued request form into a plain dict for marshmallow. + + request.form.to_dict() keeps only the first value of a repeated key, so + list fields have to be re-read with getlist(). Unchecked HTML checkboxes + are simply absent from the submission, which is not the same as a schema + default, so they are injected explicitly. Blank optional fields are + dropped rather than sent as '' — an empty password means "leave the + current one alone", not "set the password to the empty string". + + Args: + checkboxes: Names to report as True/False on presence. + list_fields: Names to read with getlist(), always producing a list. + optional_blank: Names to drop entirely when submitted empty. + """ + payload = request.form.to_dict() + for name in list_fields: + payload[name] = request.form.getlist(name) + for name in checkboxes: + payload[name] = name in request.form + for name in optional_blank: + if not payload.get(name): + payload.pop(name, None) + return payload diff --git a/app/routes/matches.py b/app/routes/matches.py index 8101bc0..9580dec 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -8,9 +8,11 @@ from datetime import datetime, timedelta from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required +from marshmallow import ValidationError from sqlalchemy.orm import joinedload from app.extensions import db +from app.forms import flash_validation_errors, form_payload from app.models import ( Admin, Coach, @@ -29,10 +31,62 @@ from app.models import ( User, ) from app.services.scheduling import notify_participants, zip_participants +from app.validators import MatchEditSchema, MatchSchema matches_bp = Blueprint('matches', __name__, url_prefix='/matches') +def match_form_payload(): + """The match form, shaped for marshmallow. + + `player_ids` is a repeated checkbox, so it needs getlist(); `games` — the + default list field — has nothing to do with this form. + """ + return form_payload(list_fields=('player_ids',), optional_blank=()) + + +#: How long a match lasts when the form gives a start and no end. +DEFAULT_MATCH_MINUTES = 30 + + +def default_end_time(date, start_time): + """End time for a match whose form left it blank.""" + return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time() + + +def create_participants(match, data): + """Attach participants to a match, per its type. + + Was written out twice, in create_match and in edit_match, and had already + drifted: the copy in edit_match kept its player ids as strings and called + int() on them one line later, the one in create_match did not (ARCH-005). + + Returns: + tuple: (player ids to notify, the participant rows created). + """ + sides = [] + if match.match_type == 'team_vs_team': + for side, team_id in ((1, match.team1_id), (2, match.team2_id)): + if team_id: + members = TeamMember.query.filter_by(team_id=team_id).all() + sides.append((side, [m.player_id for m in members])) + elif match.match_type == 'player_vs_player': + sides = [(1, data['team1_player_ids']), (2, data['team2_player_ids'])] + elif match.match_type == 'player_scrim': + sides = [(None, data['player_ids'])] + + player_ids = [] + participant_ids = [] + for side, ids in sides: + for player_id in ids: + participant = MatchParticipant(match_id=match.id, player_id=player_id, team_side=side) + db.session.add(participant) + db.session.flush() + participant_ids.append(participant.id) + player_ids.append(player_id) + return player_ids, participant_ids + + def can_schedule_match(): """Check if user can schedule matches (Admin, Manager, Coach, Scout).""" return isinstance(current_user, (Admin, Manager, Coach, Scout)) @@ -319,129 +373,53 @@ def create_match(tryout_id): all_players = sorted([p for p in all_players if p], key=lambda x: x.username) prefill_date = request.args.get('date', '') + def rerender(): + return render_template( + 'pages/match_form.html', + tryout=tryout, + teams=teams, + all_players=all_players, + prefill_date=prefill_date, + ) + if request.method == 'POST': - title = request.form.get('title') - description = request.form.get('description') - date_str = request.form.get('date') - start_time_str = request.form.get('start_time') - end_time_str = request.form.get('end_time') - location = request.form.get('location') - match_type = request.form.get('match_type') - - if not start_time_str: - flash(_('Start time is required. Please select a time slot.'), 'danger') - return render_template( - 'pages/match_form.html', - tryout=tryout, - teams=teams, - all_players=all_players, - prefill_date=prefill_date, - ) + payload = match_form_payload() + # A tryout match with no date of its own happens on the tryout's day. + payload.setdefault('date', tryout.date.isoformat()) try: - date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date - except (ValueError, TypeError): - flash(_('Invalid date format.'), 'danger') - return render_template( - 'pages/match_form.html', - tryout=tryout, - teams=teams, - all_players=all_players, - prefill_date=prefill_date, - ) - - start_time = None - end_time = None - try: - 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: - start_dt = datetime.combine(date_obj, start_time) - end_dt = start_dt + timedelta(minutes=30) - end_time = end_dt.time() - except ValueError: - flash(_('Invalid time format.'), 'danger') - return render_template( - 'pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players - ) + data = MatchSchema().load(payload) + except ValidationError as err: + flash_validation_errors(err) + return rerender() match = Match( tryout_id=tryout_id, - title=title, - description=description, - date=date_obj, - start_time=start_time, - end_time=end_time, - location=location, - match_type=match_type, + title=data['title'], + description=data['description'], + date=data['date'], + start_time=data['start_time'], + end_time=data['end_time'] or default_end_time(data['date'], data['start_time']), + location=data['location'], + match_type=data['match_type'], created_by=current_user.id, ) db.session.add(match) db.session.flush() - notified_player_ids = [] - notified_participant_ids = [] + if data['match_type'] == 'team_vs_team': + match.team1_id = data['team1_id'] + match.team2_id = data['team2_id'] - if match_type == 'team_vs_team': - team1_id = request.form.get('team1_id') - team2_id = request.form.get('team2_id') - match.team1_id = int(team1_id) if team1_id else None - match.team2_id = int(team2_id) if team2_id else None - if match.team1_id: - for m in TeamMember.query.filter_by(team_id=match.team1_id).all(): - participant = MatchParticipant( - match_id=match.id, player_id=m.player_id, team_side=1 - ) - db.session.add(participant) - db.session.flush() - notified_participant_ids.append(participant.id) - notified_player_ids.append(m.player_id) - if match.team2_id: - for m in TeamMember.query.filter_by(team_id=match.team2_id).all(): - participant = MatchParticipant( - match_id=match.id, player_id=m.player_id, team_side=2 - ) - db.session.add(participant) - db.session.flush() - notified_participant_ids.append(participant.id) - notified_player_ids.append(m.player_id) - elif match_type == 'player_vs_player': - team1_player_ids = request.form.get('team1_player_ids', '') - team2_player_ids = request.form.get('team2_player_ids', '') - team1_ids = ( - [int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else [] - ) - team2_ids = ( - [int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else [] - ) - for pid in team1_ids: - participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1) - db.session.add(participant) - db.session.flush() - notified_participant_ids.append(participant.id) - for pid in team2_ids: - participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=2) - db.session.add(participant) - db.session.flush() - notified_participant_ids.append(participant.id) - notified_player_ids = team1_ids + team2_ids - elif match_type == 'player_scrim': - player_ids = request.form.getlist('player_ids') - for pid in player_ids: - participant = MatchParticipant(match_id=match.id, player_id=int(pid)) - db.session.add(participant) - db.session.flush() - notified_participant_ids.append(participant.id) - notified_player_ids = [int(p) for p in player_ids] + notified_player_ids, notified_participant_ids = create_participants(match, data) db.session.commit() notify_participants( title=match.title, - date=date_obj, - start_time=start_time, - end_time=end_time, + date=match.date, + start_time=match.start_time, + end_time=match.end_time, participants=zip_participants(notified_player_ids, notified_participant_ids), fallback_id=match.id, ) @@ -449,13 +427,7 @@ def create_match(tryout_id): flash(_('Match scheduled successfully!'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - return render_template( - 'pages/match_form.html', - tryout=tryout, - teams=teams, - all_players=all_players, - prefill_date=prefill_date, - ) + return rerender() @matches_bp.route('//edit', methods=['GET', 'POST']) @@ -481,126 +453,74 @@ def edit_match(match_id): team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()] team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()] + def rerender(): + """The form, with everything the template needs. + + One context, used by the GET and by a rejected POST alike. The + rejection paths used to pass a shorter list, and match_form.html + serialises participants_map into a +{# The stylesheet that used to sit here — index.global.min.css — does not + exist. FullCalendar 6 bundles its styles into the JS, and that file is not + in the published package: the link had been answering 404 on every calendar + load since the upgrade. A failed stylesheet is silent in the browser, which + is why it survived. + + Integrity pins the bundle (QUA-004): this file is executable script from a + third party, on the page that shows every match in the club. See the note + in layouts/base.html for what SRI does and does not promise. #} + {% endblock %} \ No newline at end of file From a616c796633459567477463bea569675bfdd5175 Mon Sep 17 00:00:00 2001 From: cedrick2711 Date: Wed, 19 Aug 2026 21:16:25 -0400 Subject: [PATCH 75/75] regler probleme avec la creation d'equipe et 1 manager par tryout changer pour plusieurs --- app/models/_associations.py | 10 +++++++++ app/models/tryout/tryout.py | 21 +++++++++++++++--- app/models/user_model/manager.py | 14 ++++++++++-- app/routes/tryouts.py | 23 +++++++++++++++----- app/templates/pages/batch_evaluate.html | 2 +- app/templates/pages/players_to_evaluate.html | 14 +++++++++--- app/templates/pages/teams.html | 3 +++ app/templates/pages/tryout_form.html | 21 ++++++++++++------ app/templates/pages/view_tryout.html | 21 +++++++++++++----- app/validators.py | 1 + 10 files changed, 103 insertions(+), 27 deletions(-) diff --git a/app/models/_associations.py b/app/models/_associations.py index d41cc8b..a7eb124 100644 --- a/app/models/_associations.py +++ b/app/models/_associations.py @@ -37,3 +37,13 @@ tryout_coaches = db.Table( 'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True ), ) + +tryout_managers = db.Table( + 'tryout_managers', + db.Column( + 'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True + ), + db.Column( + 'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True + ), +) diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py index 6a6cbf2..1f680d2 100644 --- a/app/models/tryout/tryout.py +++ b/app/models/tryout/tryout.py @@ -1,7 +1,7 @@ """Tryout event for player evaluations and team formation.""" from app.extensions import db -from app.models._associations import tryout_coaches +from app.models._associations import tryout_coaches, tryout_managers from app.time_utils import utc_now_naive @@ -20,16 +20,17 @@ class Tryout(db.Model): max_players = db.Column(db.Integer, nullable=True) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) - manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration coach_id = db.Column( db.Integer, db.ForeignKey('users.id'), nullable=True ) # deprecated, kept for migration created_at = db.Column(db.DateTime, default=utc_now_naive) creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') - manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') + manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts') coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts') + managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m') registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic') evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic') teams = db.relationship('Team', backref='tryout', lazy='dynamic') @@ -47,3 +48,17 @@ class Tryout(db.Model): if self.end_date is not None: return self.end_date < today return self.date < today + + def get_managers(self): + """Managers attached to this tryout, both legacy and many-to-many.""" + manager_list = list(self.managers) + if not manager_list and self.manager: + return [self.manager] + return manager_list + + def get_coaches(self): + """Coaches attached to this tryout, both legacy and many-to-many.""" + coach_list = list(self.coaches) + if not coach_list and self.coach: + return [self.coach] + return coach_list diff --git a/app/models/user_model/manager.py b/app/models/user_model/manager.py index aa95fb3..501b111 100644 --- a/app/models/user_model/manager.py +++ b/app/models/user_model/manager.py @@ -21,7 +21,11 @@ class Manager(User): return True def can_manage_this_tryout(self, tryout): - return tryout.created_by == self.id or tryout.manager_id == self.id + return ( + tryout.created_by == self.id + or tryout.manager_id == self.id + or any(m.id == self.id for m in tryout.managers) + ) def can_manage_this_org_team(self, org_team): return True @@ -32,7 +36,13 @@ class Manager(User): from app.models.tryout.tryout import Tryout return ( - Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id)) + Tryout.query.filter( + or_( + Tryout.created_by == self.id, + Tryout.manager_id == self.id, + Tryout.managers.any(id=self.id), + ) + ) .order_by(Tryout.date) .all() ) diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index db2468c..e537463 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -51,7 +51,7 @@ def can_manage(): def tryout_form_payload(): """The tryout form, shaped for marshmallow (ARCH-005).""" - return form_payload(list_fields=('coach_ids',), optional_blank=()) + return form_payload(list_fields=('coach_ids', 'manager_ids'), optional_blank=()) def coaches_from_ids(coach_ids): @@ -67,6 +67,13 @@ def coaches_from_ids(coach_ids): return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all() +def managers_from_ids(manager_ids): + """The manager accounts behind these ids, filtered by role.""" + if not manager_ids: + return [] + return User.query.filter(User.id.in_(manager_ids), User.role == 'manager').all() + + def _users_by_id(user_ids): """Load these users in one query, keyed by id. @@ -160,12 +167,12 @@ def create_tryout(): created_by=current_user.id, status='upcoming', target_org_team_id=data['target_org_team_id'], - manager_id=data['manager_id'], ) db.session.add(tryout) db.session.flush() tryout.coaches = coaches_from_ids(data['coach_ids']) + tryout.managers = managers_from_ids(data['manager_ids']) db.session.commit() flash(_('Tryout created successfully!'), 'success') @@ -221,8 +228,14 @@ def edit_tryout(tryout_id): tryout.location = data['location'] tryout.max_players = data['max_players'] tryout.target_org_team_id = data['target_org_team_id'] - tryout.manager_id = data['manager_id'] - tryout.coaches = coaches_from_ids(data['coach_ids']) + + # Only update staff lists when the form explicitly sends them. + # An absent checkbox group (all unchecked or JS failed) means + # \"don't change\", not \"remove everyone\". + if 'coach_ids' in request.form: + tryout.coaches = coaches_from_ids(data['coach_ids']) + if 'manager_ids' in request.form: + tryout.managers = managers_from_ids(data['manager_ids']) db.session.commit() flash(_('Tryout updated successfully!'), 'success') @@ -241,7 +254,7 @@ def view_tryout(tryout_id): if isinstance(current_user, Admin): can_view = True elif isinstance(current_user, Manager): - can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id + can_view = current_user.can_manage_this_tryout(tryout) elif isinstance(current_user, Coach): can_view = current_user.can_manage_this_tryout(tryout) elif isinstance(current_user, Player): diff --git a/app/templates/pages/batch_evaluate.html b/app/templates/pages/batch_evaluate.html index 815e763..0140f15 100644 --- a/app/templates/pages/batch_evaluate.html +++ b/app/templates/pages/batch_evaluate.html @@ -43,7 +43,7 @@
- + {{ existing_scores[field_name] or 5 }}
diff --git a/app/templates/pages/players_to_evaluate.html b/app/templates/pages/players_to_evaluate.html index 93c8048..a6f2127 100644 --- a/app/templates/pages/players_to_evaluate.html +++ b/app/templates/pages/players_to_evaluate.html @@ -15,7 +15,7 @@ - + @@ -70,12 +70,20 @@ - {% endblock %} diff --git a/app/templates/pages/teams.html b/app/templates/pages/teams.html index a4b56b6..c1e0d93 100644 --- a/app/templates/pages/teams.html +++ b/app/templates/pages/teams.html @@ -300,6 +300,9 @@ +{% endblock %} + +{% block scripts %}
Player Contact Attendance