Author SHA1 Message Date
GGThed 06508cc7d6 Merge immortal/main into audit branch 2026-08-17 00:28:06 -04:00
GGThed 9647003c3f fix(auth): garder l identite Discord du cote verifie 2026-08-16 23:36:30 -04:00
cedrick2711 2b943c5c22 régler problème avec le bouton pour sauvegarder les dispo 2026-08-14 12:03:39 -04:00
GGThed 437b229c82 Merge immortal/main into audit/securite-maintenabilite-standards 2026-08-12 14:58:28 -04:00
GGThedandClaude Opus 5 3b7b9182d7 fix(authz): balayer le motif au lieu d attendre la passe suivante
Le commit precedent finissait teams.py en notant que le defaut venait d'une
correction appliquee a un seul endroit. Balayer les autres modules
immediatement, plutot que d'attendre qu'une passe d'audit les retrouve, a
sorti les deux derniers.

tryouts.register_player lisait int(request.form.get('player_id')) -- 500 sur
une valeur non numerique -- et verifiait le role sans regarder
is_active_account. Un compte desactive pouvait donc etre inscrit a une
selection.

users/contracts._selectable_players ne filtrait pas non plus les comptes
desactives dans sa branche non-coach : la liste de depot de contrat proposait
encore des gens partis du club. Un contrat est un document nominatif signe.

PlayerSelectionSchema porte desormais le champ, et TeamPlayerSchema en herite
en ajoutant son statut. Un schema partage est ce qui empeche le prochain
appelant d'etre oublie -- c'est precisement parce que chaque route avait le
sien, ecrit a la main, que la correction a du etre faite trois fois.

Verifie par mutation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 20:48:55 -04:00
GGThedandClaude Opus 5 20dabecd67 fix(authz): finir SEC-16, que mon propre correctif avait laisse a moitie
La vague K a pose un schema sur create_team et edit_team et a laisse cinq
routes soeurs du meme fichier lire int(request.form.get(...)) : add_coach,
add_manager, remove_coach, remove_manager et add_player. Un identifiant non
numerique y etait un 500 dans chacune.

C'est exactement la lecon que ce projet repete depuis la vague D -- corriger
un motif fautif dans une seule couche le laisse dans les autres -- et cette
fois c'est le correctif lui-meme qui l'a commise. Notee comme telle.

Deux defauts de plus, trouves en finissant.

add_player ecrivait status tel quel dans une colonne NOT NULL String(20). Et
toggle_player_status lit "substitute si status == starter, sinon starter" :
une valeur inconnue devenait donc starter a la premiere bascule, c'est-a-dire
promouvait son porteur. Liste blanche dans TEAM_PLAYER_STATUSES.

Et aucune de ces routes ne regardait is_active_account. La requete qui
alimente la liste deroulante des joueurs ne le filtrait pas non plus, alors
que les deux requetes juste au-dessus, coachs et gerants, le posaient -- deux
lignes d'ecart, meme fichier. Un compte desactive etait donc propose et
accepte, alors que is_active_account est precisement ce qui dit que la
personne a quitte le club. Meme oubli dans tryouts.py.

_staff_member delegue desormais a _assignable au lieu de repeter isinstance :
deux fonctions du meme fichier repondant differemment a "ce compte peut-il
prendre ce role" est la forme de tous les defauts qu'a eus ce module.

Verifie par mutation : retirer le controle d'activite ou la liste blanche
fait tomber trois tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 20:42:26 -04:00
GGThedandClaude Opus 5 838b247649 feat(perf): borner les vues de liste, et choisir le motif une bonne fois
MNT-14. Chaque vue de liste faisait .all() sur sa table. L'audit evaluait
l'impact a nul -- justement, a l'echelle d'une association etudiante -- et
recommandait de choisir le motif maintenant plutot que de le retro-adapter
plus tard. C'est ce que ceci est.

Applique a list_users, list_evaluations et team_matches.list_matches. Pour
cette derniere, la pagination borne aussi la boucle sur les participants,
qui est le N+1 que le constat designait comme le premier a se degrader.

Trois decisions, parce que ce sont celles qui se prennent deux fois
differemment sinon.

error_out=False : les numeros de page arrivent par l'URL, donc ?page=999 est
une chose qu'on tape ou qu'un signet perime contient. Le defaut de
Flask-SQLAlchemy y repond par un 404, ce qui est deroutant pour quelqu'un qui
est simplement alle une page trop loin.

Un plafond sur per_page : c'est aussi un parametre d'URL, et sans plafond
?per_page=100000 redonne a la main exactement la requete non bornee que la
pagination existe pour empecher.

page_url est un global Jinja plutot qu'une valeur que chaque vue passe. Ce
qui se rate avec des liens de pagination, c'est le reste de la chaine de
requete : la liste d'evaluations porte sort et order, celle des matchs
d'equipe porte team_id, et un lien qui les perd reinitialise silencieusement
la vue que la personne regardait. Les deux tests qui l'epinglent tombent si
page_url cesse de les recopier -- verifie par mutation.

Les tris sont completes par une cle unique : une requete paginee sans ORDER
BY stable peut montrer la meme ligne deux fois et jamais une autre.

Au passage, huit entrees fuzzy corrigees dans les catalogues, dont deux
laissees par le commit SEC-16 : une entree fuzzy est ignoree a l'execution,
donc ces messages retombaient en anglais.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 20:20:42 -04:00
GGThedandClaude Opus 5 9166d8abeb fix(data): une saisie d heure refusee n efface plus la disponibilite
MNT-12. Le meme bloc de parsing date/heure vivait dans quatre modules avec
trois reponses differentes a la meme saisie invalide : signaler et rediriger,
mettre la valeur a None et annoncer la reussite, ou passer au suivant en
silence. Les vagues E et G ont ferme le cote matchs avec des schemas ; il
restait la disponibilite, les creneaux de coach et les demandes individuelles.

Deux choses trouvees en appliquant, aucune dans le constat.

OneOnOneRequestSchema et DisponibilityAddSchema etaient definis dans
validators.py et appeles NULLE PART : aucun import, aucun test. C'est le
motif SEC-AUTHZ-001 -- une politique de validation ecrite et non appliquee --
qui survivait dans un coin que personne n'avait rouvert. Les deux passaient
d'ailleurs par des fields.String + Regexp, qui verifient la forme et laissent
l'appelant convertir ; fields.Date et fields.Time font les deux.

Et manage_coach_availability supprimait tous les creneaux existants avant de
reajouter ceux qu'il savait lire, ignorant les autres en silence et repondant
{'success': true}. Un envoi malforme effacait donc les heures reservables
d'un coach en annoncant la reussite -- et les demandes individuelles sont
refusees contre exactement cette table, donc le coach devenait injoignable
sans que rien ne le dise. Une operation de remplacement doit tout valider
avant de rien supprimer : le lot est refuse en entier.

Trouve aussi : le controle de disponibilite comparait les chaines du
formulaire aux chaines serialisees, ce qui ne marchait que parce que les deux
cotes etaient en HH:MM a zero non significatif. La comparaison porte
desormais sur des objets time.

Et le lint a rattrape une regression que la relecture avait manquee --
sixieme fois : datetime retire de one_on_one.py alors que deux fonctions non
touchees l'utilisaient encore.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 20:20:25 -04:00
GGThedandClaude Opus 5 9cedf4a038 fix(ops): nginx pointait sur le port du serveur de developpement
STD-06, de l'audit anterieur : "ports et adresses d'ecoute incoherents entre
cinq fichiers". ARCH-007 a unifie les points d'entree Python en vague E/F et
OPS-002 a fait de HOST et PORT des variables en vague H, mais personne n'est
retourne dans app/nginx.conf.

proxy_pass pointait sur 127.0.0.1:5000, qui est le defaut de run.py, le
serveur de developpement. wsgi.py -- ce qui sert reellement la production --
ecoute PORT avec un defaut de 10000. Installer le fichier tel qu'il est
livre donne donc 502 sur chaque page, depuis une configuration qui se lit
comme parfaitement raisonnable et un serveur qui tourne tres bien.

Rien dans le depot ne reliait les deux nombres, donc rien ne pouvait
remarquer qu'ils avaient diverge. Le test est cette relation, ecrite
quelque part qui s'execute. Il couvre aussi les blocs commentes : celui
qu'on decommente dans un an porte le port avec lequel il a ete ecrit.

Le second test garde l'avertissement colle au chemin alias du bloc /static/,
livre avec une valeur devinee et toujours a regler sur le noeud.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 19:37:25 -04:00
GGThedandClaude Opus 5 dd8b9671c6 fix(authz): teams.py etait le module que la validation n avait pas atteint
SEC-16, de l'audit anterieur. La vague G a pose un schema a la frontiere de
matches, team_matches, tryouts et evaluations, et le HANDOFF en a tire une
regle : tout champ de formulaire passe par un schema de app/validators.py,
pas par request.form.get. teams.py ne l'avait jamais appliquee.

Deux defauts, pas un.

int(request.form.get('coach_id')) leve sur une valeur non numerique : une
soumission fabriquee etait un 500.

Et l'identifiant obtenu etait ensuite resolu sans verifier le role du compte,
dans deux des trois endroits qui le faisaient. La branche sync_staff
d'edit_team testait isinstance(user, Coach) ; son autre branche non, et
create_team non plus. Le meme fichier en desaccord avec lui-meme, sur
exactement le defaut que la vague G avait corrige dans tryouts.py -- une
soumission fabriquee pouvait nommer un joueur parmi les coachs d'une equipe.
L'identifiant vient d'un <select> rendu par le navigateur : c'est une valeur
que le client choisit.

_staff_member est la reponse unique, et OrgTeamSchema garantit que les
identifiants arrivent en entiers. Les deux tests qui epinglent le role
tombent si la verification saute : verifie par mutation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 19:37:13 -04:00
GGThedandClaude Opus 5 ad3dea6a15 fix(web): une erreur sur un point JSON ne renvoie plus une page HTML
STD-09, trouve en recroisant l'audit anterieur -- celui mene sur le miroir
GitHub, jamais repasse depuis qu'on a decouvert que ce n'etait pas la bonne
source.

Sept gestionnaires d'erreur portaient chacun leur copie d'une liste de
prefixes d'URL decidant "JSON ou page HTML". Les copies avaient derive --
trois testaient /users/coach-availability, quatre non -- et toutes
manquaient les memes points. Un fetch() qui recoit une page d'erreur HTML
leve en la parsant : sur le calendrier, les listes de selections et
d'equipes restaient vides, sans message dans la page et sans rien dans le
journal.

Deux choses apprises en ecrivant le test, aucune n'etait dans le constat.

L'approche par prefixe ne pouvait pas etre reparee. Trois des seize vues
JSON sont a des chemins qu'aucun prefixe ne distingue des pages HTML
voisines -- /matches/<id>/toggle-presence/<id> et ses deux cousins, que les
gabarits appellent justement en fetch(). Les vues se declarent donc
elles-memes (@json_endpoint, app/api.py), et un test parcourt la carte des
URL pour verifier qu'aucune vue appelant jsonify n'a ete oubliee.

Et surtout : @login_required n'atteint jamais le gestionnaire 401.
Flask-Login intercepte avant et redirige. Les seize points JSON repondaient
donc a une session expiree par une 302 vers un formulaire HTML, quoi que
dise la liste de prefixes. Reecrire la liste seule aurait eu l'air d'un
correctif sans rien changer.

Au passage, le message flash de ce gestionnaire etait la seule chaine de
l'application qui n'avait jamais ete traduite.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 19:36:25 -04:00
GGThedandClaude Opus 5 8d7de75e99 chore(ops): nommer le stockage du rate limiting au lieu de le subir
SEC-WEB-004. Limiter() ne nommait aucun stockage, donc Flask-Limiter
retombait sur memory://. Le choix n'avait jamais ete fait : c'etait
simplement ce qui arrivait.

Pour un seul processus Waitress, memory:// est la bonne reponse -- ce qui
est precisement pourquoi il fallait l'ecrire. Un deuxieme worker laisserait
passer deux fois chaque limite, en silence, avec une configuration qui a
l'air inchangee. RATELIMIT_STORAGE_URI rend la valeur lisible dans .env,
modifiable en une ligne le jour ou le deploiement gagne un processus, et le
demarrage journalise laquelle est active.

La part qui reste bloquee est nommee dans le code : un stockage partage ne
rend pas les limites solides tant qu'elles sont indexees sur une adresse IP
falsifiable, c'est-a-dire tant qu'OPS-002 / SEC-WEB-002 n'est pas tranche
avec le developpeur. C'est pour cela que celui-la est le prerequis et pas
celui-ci.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 18:35:27 -04:00
GGThedandClaude Opus 5 06d6ad7eaa feat(ops): un bot qui tombe se releve, ou dit pourquoi il ne peut pas
OPS-004. bot.run() qui rend la main signifie que la connexion est perdue
pour de bon : discord.py se reconnecte seul pour tout ce qui est
recuperable. Ce qui se passait ensuite, c'etait rien. Le fil se terminait,
bot_thread restait non nul donc start_bot n'en relancerait jamais un autre,
et l'application web continuait a servir des pages pendant que toutes les
notifications et tous les rappels quotidiens s'etaient arretes. La vague E/F
avait fait la moitie visibilite (OPS-012), pas la moitie reprise. La panne
pouvait durer des semaines.

Deux fins sont distinguees, parce que reessayer ne sert que pour l'une.
Un jeton rejete ou un intent privilegie manquant est une erreur de
configuration : boucler dessus ne fait que marteler le point de connexion de
Discord, ce qui est la maniere d'obtenir une limitation ou un bannissement.
Le reste est traite comme une panne et reessaye avec une temporisation
exponentielle, plafonnee a cinq minutes, tant que le processus vit.

La temporisation se reinitialise apres une connexion qui a dure. Sinon un
bot qui tourne un mois puis decroche attend cinq minutes avant son premier
essai, fort d'un incident depuis longtemps termine.

Deux consequences de conception. Une instance neuve a chaque tentative :
discord.py ferme le client quand run() rend la main, et un client ferme ne
se reconnecte pas -- le reutiliser transforme une reprise en fil qui tourne
sur une exception. Et donc la file de messages passe au niveau module,
sinon chaque redemarrage emporterait les notifications en attente.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 18:35:17 -04:00
GGThedandClaude Opus 5 e76cd7bb23 fix(authz): aligner la suppression d equipe, sans elargir l acces
SEC-AUTHZ-006. delete_team etait la seule des dix operations d'equipe
gardee par la capacite globale can_manage_teams() ; les neuf autres passent
par can_manage_this_org_team(team). Le constat avait raison sur
l'incoherence et tort sur le correctif.

Appliquer sa recommandation telle quelle -- remplacer par la verification
par objet -- ELARGIT l'acces. Coach repond False a la capacite globale et
True pour ses propres equipes : la substitution donnait donc a chaque coach
le pouvoir de supprimer l'equipe qu'il entraine, avec ses notes d'equipe et
son historique de matchs. Le constat raisonnait sur Manager, ou les deux
repondent True, et a manque le role ou elles divergent.

Les deux sont donc exigees. Le comportement d'aujourd'hui est preserve a
l'identique (administrateurs et gerants oui, coachs non) et la dette que le
constat visait est bien fermee : le jour ou Manager.can_manage_this_org_team
sera resserre -- ce qui est souhaitable -- la suppression se resserrera avec
lui au lieu de rester la seule porte ouverte.

C'est la quatrieme recommandation d'audit qu'il faut corriger avant de
l'appliquer. Le test qui l'epingle echoue si quelqu'un refait la
simplification : verifie par mutation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 18:35:05 -04:00
GGThedandClaude Opus 5 bda23dbb67 fix(ops): la sauvegarde des contrats archivait le mauvais repertoire
OBS-006. Trois racines etaient baties sur os.getcwd() : le magasin de
documents, les journaux et les sauvegardes. La vague G a corrige la
premiere, parce qu'elle bloquait aussi OPS-011, et a laisse les deux autres.
C'est la lecon deja consignee deux fois : un motif fautif corrige dans une
seule couche reste dans les autres.

Le plus serieux n'est pas le motif, c'est l'ecart qu'il a ouvert.
backup.py gardait sa propre constante DOCUMENTS_DIR sur os.getcwd(), donc
il ignorait DOCUMENTS_ROOT -- la variable que la vague G a introduite et que
docs/deployment.md dit maintenant de regler pour sortir les televersements
des repertoires de version. Des qu'un exploitant suit cette consigne, le
script archive un repertoire ou l'application n'a jamais rien ecrit. Et
comme il repond a un repertoire absent par une ligne d'information et un
code de sortie 0, une tache planifiee qui surveille le code de sortie voit
vert indefiniment.

Autrement dit : plus l'exploitant suivait correctement la documentation de
deploiement, plus surement ses sauvegardes de contrats etaient vides.

Les trois racines viennent desormais d'app/storage.py, resolues a l'appel et
non a l'import, et la sauvegarde imprime la source qu'elle a utilisee. Le
message d'absence nomme le chemin ou elle a cherche : "No documents
directory found" se lisait comme "il n'y a pas de documents" plutot que
comme "je regarde au mauvais endroit".

Le test qui porte est celui qui ouvre l'archive : un zip vide est un fichier
de taille non nulle, donc verifier qu'un fichier a ete produit ne prouvait
rien. Verifie par mutation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 18:34:52 -04:00
GGThedandClaude Opus 5 66f2838402 chore(lint): interdire d avaler une exception sans laisser de trace
Regle BLE de ruff activee. Ce qu'elle enforce n'est pas "ne jamais attraper
large" : elle se satisfait d'un logger.exception. C'est exactement la
discipline visee — une frontiere peut tout avaler, a condition de laisser de
quoi distinguer un defaut d'une panne. Les cinq noqa que j'avais prepares
d'avance etaient donc inertes ; la raison reste en commentaire simple.

Ce que la regle a trouve, une fois activee :

app.py, demarrage du bot — les deux facons d'echouer, jeton invalide et
import casse, se lisaient a l'identique sur une seule ligne et aucune
n'etait diagnosticable. Passe en error avec exc_info : un club qui ne
recoit plus aucun rappel a perdu une fonctionnalite, et warning mettait ca
a cote des avis de depreciation.

services/notifications.py — le bloc webhook attrapait large autour d'un
requests.post. RequestException couvre toutes les facons dont un appel HTTP
echoue ; le reste est un defaut. La branche DM et la branche webhook etaient
en plus imbriquees dans un seul try alors qu'elles s'excluent.

logging_config.py et les deux scripts CLI gardent leur largeur, avec la
raison sur la ligne. Le filtre de journalisation est le cas ou la trace que
BLE001 reclame est precisement ce qu'il ne faut pas produire : journaliser
depuis un filtre rentre dans le meme filtre.

RUF100 (noqa inutile) n'est volontairement pas active : il ferait remonter
des directives preexistantes sans rapport avec ce chantier.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 16:29:13 -04:00
GGThedandClaude Opus 5 546f28571b fix(bot): une reaction qui echoue le dit, au lieu de se taire
Chaque gestionnaire de discord_bot.py enveloppait tout son corps dans un
except Exception qui journalisait et poursuivait. Un refus de la base, une
boite de reception fermee et une faute de frappe dans ce module produisaient
la meme ligne, et la personne qui avait clique n'apprenait rien dans aucun
des trois cas.

Le defaut que l'audit citait en exemple : dans handle_attendance_confirm, le
commit et le message "Your attendance has been confirmed!" etaient dans le
meme bloc protege. Si le commit levait, rien n'etait envoye et rien n'etait
signale. La reaction devenait indiscernable d'un bot arrete.

Trois familles, trois reponses. La base refuse : rollback, l'entree pending
est conservee pour que la reaction reste reessayable, et la personne est
prevenue que rien n'a ete enregistre. Discord est injoignable : apres un
commit c'est du meilleur effort, un DM qui rebondit ne defait pas une
decision prise. Tout le reste est un defaut et remonte, jusqu'a on_error,
qui est ajoute parce que discord.py journalise sur le logger 'discord' que
configure_logging ne collecte pas.

Trouve en appliquant : deux reactions sur le meme message passent toutes
deux le test d'appartenance puis s'attendent sur deux await, et la perdante
levait un KeyError qui se lisait comme une erreur sans consequence ; un
fetch_channel en echec renvoyait sans un mot, donc un clic sans effet et
sans trace ; un start_scheduler en echec supprime tous les rappels a jamais
et laissait trois cles de /health au vert, d'ou reminders_scheduled.

L'ordre des etapes apres le commit est desormais fixe : oublier l'entree
pending avant les messages, sinon un DM rebondi laisse une demande deja
approuvee reactivable une seconde fois.

Les tests ont ete verifies par mutation du code de production. La deuxieme
mutation a trouve une faiblesse dans le test lui-meme, qui ne regardait que
le premier message emis.

QUA-004 (roadmap) / ARCH-008 (constats).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 16:28:58 -04:00
GGThed 7a1dab21cd docs: les deux documents d exploitation qui manquaient
DOC-001 demandait quatre documents courts. Deux existaient — installation
(deployment.md) et restauration (database-restore.md). Les deux autres
n existaient pas.

docs/roles-and-permissions.md decrit le comportement **implemente**, pas
celui qu on souhaiterait, et note les endroits ou les deux divergent : un
gerant gere toutes les equipes mais pas toutes les selections, asymetrie
presente depuis toujours et ecrite nulle part ailleurs. Le piege du double
rattachement coach/equipe y est en toutes lettres, avec le motif a ne
jamais reintroduire.

Le document dit aussi qui a raison en cas de desaccord : les tests. Une
documentation d autorisation qui se contredit avec le code est pire que
pas de documentation, parce qu on la croit.

docs/incident-runbook.md part du symptome, pas du composant. Le reflexe
d ouverture est la reference affichee sur la page d erreur — c est ce que
l identifiant de requete rend possible, et sans un endroit qui le dise, la
fonctionnalite ne sert a personne.

Trois affirmations ont ete verifiees contre le code avant d etre ecrites,
et deux etaient fausses : le corps de /health en echec dit
database="error" et non "disconnected", et les evenements de verrouillage
s appellent login.failure et account.throttled. La liste complete des
quatorze evenements de auth.log est maintenant dans le manuel.

README : index des documents, et deux points d etat corriges — TRUSTED_PROXY
est desormais une variable, et l absence de validation des inscriptions par
le staff est nommee comme decision en attente.
2026-08-11 15:40:30 -04:00
GGThed 709e8a5d51 fix(data): supprimer un compte emporte ses contrats
DATA-012. delete_user supprimait les lignes Contract et laissait les PDF.
Des contrats nominatifs signes restaient donc sur le serveur apres la
suppression du compte, sans plus aucune reference en base : invisibles pour
l application, ingerables par elle, et toujours des donnees personnelles.

Les chemins sont lus **avant** que les lignes partent — apres, plus rien ne
dit ou sont les fichiers — et les fichiers sont retires **apres** le commit.
L ordre compte dans ce sens et pas dans l autre : un echec entre les deux
doit laisser un fichier sans ligne, ce qui est recuperable et correspond
exactement a l etat precedent, plutot qu une ligne sans fichier, qui est un
telechargement en 500 pour toujours.

Un fichier deja absent est journalise en info et ignore ; un fichier
impossible a retirer est journalise en erreur avec ce que ca implique — il
devient orphelin, donc plus rien dans l application ne proposera jamais de
le supprimer. Rien ici ne peut faire echouer la suppression du compte : le
compte est la partie que quelqu un a demandee.

Le nombre de fichiers retires part dans le journal d authentification, a
cote de account.deleted.

554 tests.
2026-08-11 15:34:33 -04:00
GGThed 70db8a7491 feat(obs): nommer chaque requete, et rendre les pages d erreur audibles
OBS-005. Un 500 dans errors.log et les six lignes de app.log qui y menent
n etaient relies que par leur horodatage — ce qui n est pas une relation
des que le serveur traite plus d une requete a la fois. Et un utilisateur
qui dit « ca a plante quand j ai clique sur enregistrer » ne donnait a
personne de quoi chercher.

Chaque requete recoit un identifiant, porte par toutes les lignes de
journal qu elle produit, renvoye en X-Request-Id, et affiche sur la page
500 comme reference a citer.

Il est **genere**, jamais lu depuis un en-tete entrant. Accepter celui du
client serait pratique pour tracer a travers nginx, et permettrait aussi a
n importe qui d ecrire du texte arbitraire — retours a la ligne compris —
dans le fichier de journal. C est ainsi qu un journal cesse d etre une
preuve. Il n y a de toute facon aucun proxy de confiance tant qu OPS-002
est ouvert.

Le test correspondant assure sur l alphabet plutot qu en envoyant un
retour a la ligne : le client de test de Werkzeug refuse d emettre un tel
en-tete, donc l attaque ne peut meme pas etre construite par la, ce qui ne
prouverait rien sur l application.

**Defaut trouve en chemin, et repare.** Les cinq gabarits d erreur
remplissent le bloc `content`, qui n existait que dans la branche
authentifiee de la mise en page. Un visiteur deconnecte tombant sur une
erreur — donc typiquement sur la page de connexion — recevait le logo, le
selecteur de langue, et **aucun message**. Le code de statut etait bon, les
journaux etaient bons, la page etait vide. Le <title> disait quand meme
« 404 », ce qui explique en grande partie que personne ne l ait vu.

Le bloc est desormais rendu dans les deux branches via self.content(),
Jinja refusant deux blocs de meme nom. Un seul cote du if s execute, donc
jamais de double rendu — et c est assure, pas suppose.

550 tests.
2026-08-11 15:30:05 -04:00
GGThed 7b9eee4805 feat(db): mesurer la derive du schema, au lieu de la supposer
DB-001. create_all() cree les tables manquantes et ne fait jamais d ALTER.
Une colonne ajoutee a un modele il y a six mois est donc absente de toute
base qui possedait deja la table, et rien ne le dit : l application demarre,
et la premiere requete qui touche cette colonne echoue a l execution. C est
la raison d etre de migrations/add_tryout_coaches.py, ecrit a la main pour
rattraper un cas. Personne ne sait combien il y en a d autres.

app/supporting_scripts/schema_report.py compare le catalogue d une base
vivante aux modeles : tables, colonnes, types, nullabilite, contraintes
d unicite, cles etrangeres, index. En **lecture seule** — il ouvre une
connexion, lit, imprime, sort. Aucun DDL, aucun DML.

Les constats sont classes par ce qu ils coutent, pas par ce qu ils sont :

- BLOCKING : les modeles l attendent, la base ne l a pas. C est la derive ;
- RISK : la base l a, aucun modele ne le decrit. Inoffensif tant que rien
  ne bouge — et **un alembic --autogenerate proposera de le supprimer**,
  avec ses donnees. C est la classe qu on lit en entier ;
- DIFFERENCE : types, nullabilite, contraintes. Chacune demande un humain.

Les types sont compares apres compilation vers le meme dialecte : opposer
String(200) a VARCHAR(200) en chaines aurait signale chaque colonne comme
differente, et un rapport qui crie partout ne se lit plus.

--check-seed-accounts repond a la question de SEC-003 a laquelle le depot
ne peut pas repondre : le compte admin/password seme par clear_db.py
existe-t-il encore, et son mot de passe est-il toujours celui-la.

13 tests le pilotent contre des bases SQLite fabriquees pour diverger d une
facon connue. Le cas qui compte le plus est la base propre : un rapport qui
crie sur une base saine ne sera pas lu, et un rapport qui dit « aucun
ecart » sur une base derivee est pire que pas de rapport — c est un feu
vert pour laisser autogenerate ecrire la difference en DROP.

docs/database-schema.md donne la suite, etape par etape, avec le piege de
DB-002 en toutes lettres : la migration initiale doit decrire la base telle
qu elle est, pas telle que les modeles la decrivent. Generer depuis les
modeles puis estampiller revient a declarer que la derive n existe pas.

Alembic n est pas ajoute aux dependances : rien ne l utilise encore, et une
dependance que rien n utilise est exactement ce que l audit reprochait
ailleurs. Le document dit a quelle etape l ajouter.

530 tests.
2026-08-11 15:00:18 -04:00
GGThed 3882b6035f fix(ops): defauts surs a la copie, CDN epingles, actions epinglees
Quatre taches de la matrice du rapport, toutes sans dependance, qu aucune
liste de « ce qui reste » ne reprenait.

OPS-003 — app/.env.exemple disait « copiez ce fichier et remplissez les
valeurs pour la production », puis posait FLASK_DEBUG=true,
SESSION_COOKIE_SECURE=false et FORCE_HTTPS=false. Le debogueur Werkzeug
execute du code soumis par le navigateur : cette ligne transformait un
copier-coller en shell distant. Chaque valeur est desormais sure a la
copie, et le fichier refuse de demarrer tant que les deux secrets
obligatoires ne sont pas remplis plutot que de demarrer grand ouvert.

Renomme en .env.example : l orthographe francaise ne correspondait pas a
l exception !.env.example du .gitignore, donc le fichier n etait suivi que
par accident de l ordre des regles. Les deux points de la decision ouverte
du §8 tombent d un seul git mv.

OPS-002 — trusted_proxy='*' et HOST ne sont plus soudes dans wsgi.py. Les
defauts sont **inchanges**, deliberement : choisir sans connaitre la
topologie coupe la prod si nginx est ailleurs, ou casse la limitation de
debit pour tout le monde si on cesse de croire X-Forwarded-For alors que
c etait la seule source d adresses. Ce sont maintenant des variables, les
valeurs sures sont dans .env.example pour un nouveau deploiement, et
docs/deployment.md donne les quatre topologies avec la valeur de chacune.
wsgi.py avertit au demarrage tant que les deux defauts sont en place.

Le commentaire de HOST annoncait « bind to localhost by default » a cote
d un defaut a 0.0.0.0 : il decrivait l intention pendant que le code
faisait l inverse. Il dit maintenant ce qu il fait.

QUA-004 — Font Awesome et FullCalendar etaient charges sans empreinte,
depuis des hotes que la CSP autorise nommement. Qui controle ces CDN
controlait ce qui s execute sur chaque page. Empreintes posees, avec ce
que SRI promet et ce qu il ne promet pas ecrit a cote : ca fige le fichier,
ca ne prouve pas qu il etait honnete au moment du calcul.

**Le CSS de FullCalendar n existait pas.** La v6 embarque ses styles dans
le JS et ce fichier n est pas publie : le <link> repondait 404 a chaque
ouverture du calendrier depuis la montee de version. Une feuille de style
en echec est silencieuse dans le navigateur, c est ce qui l a fait durer.

CI-003 — actions epinglees sur un commit, version en commentaire, dans les
deux forges. Un tag est un pointeur mobile : deplacer v4 fait executer du
code arbitraire dans le job qui detient la cle SSH de production. Ce job
recoit aussi enfin un bloc permissions.

517 tests.
2026-08-11 14:42:23 -04:00
GGThed 39808dd04e ops: un deploiement qui refuse de partir casse, et qui se verifie
OPS-011, en partie. Ce que le workflow garantit maintenant :

- rien ne part d un arbre casse. La suite, ruff check et ruff format
  tournent sur le runner de deploiement avant tout envoi. Une CI verte sur
  GitHub ne prouve rien ici : le deploiement se declenche a la main, sur ce
  que la branche contient a cet instant ;
- seuls les fichiers nommes partent. La charge est une liste blanche —
  app/, wsgi.py, requirements.txt — et non l arbre de travail moins neuf
  exclusions. C est par cette porte que clear_db.py, la suite de tests et
  les definitions de CI se sont retrouves sur le noeud de production ;
- le deploiement est verifie. /health est interroge pendant deux minutes
  apres l envoi et le job echoue s il ne repond jamais « healthy ». Avant,
  un arbre a moitie televerse etait un deploiement vert.

Ce qui n est pas garanti, et c est ecrit dans le fichier : la bascule n est
pas atomique. Le miroir se fait sur place, donc pendant le transfert la
production execute un melange de deux versions.

En cherchant a fermer ce point, un defaut a part entiere est apparu. Les
contrats etaient ranges a os.getcwd()/documents et leur chemin absolu
ecrit en base. La racine de stockage suivait donc le repertoire depuis
lequel le processus avait ete lance : redemarrer le serveur ailleurs
envoie les nouveaux contrats dans un nouvel arbre et rend les anciens
illisibles — la base continuant d affirmer qu ils sont la, la panne se
manifeste par un 500 au telechargement, pas par quelque chose
d actionnable.

app/storage.py fixe la racine et DOCUMENTS_ROOT la deplace. Les nouvelles
lignes gardent un chemin relatif, les anciennes gardent leur chemin absolu
et continuent de resoudre : aucune migration de donnees n est necessaire,
donc ce changement n attend pas Alembic.

C etait aussi le troisieme pre-requis de la bascule par repertoires de
version. Les deux autres sont hors d atteinte d ici — la commande de
demarrage Pterodactyl doit pointer sur current/, et les repertoires
partages doivent etre installes sur le noeud. Les deux sont decrits dans
docs/deployment.md, avec la procedure de retour arriere qui manquait.

511 tests.
2026-08-11 13:58:54 -04:00
GGThed 506a061405 test: les regles metier des selections, ecrites noir sur blanc
QUA-003. La suite couvrait securite, autorisation, i18n, CSP et forme des
requetes. Elle ne couvrait pas les regles pour lesquelles l application
existe : qui entre dans une selection, combien, et ce qui cesse d etre
modifiable une fois qu elle est finie.

Aucun defaut corrige ici. Ce sont les garanties que les routes tiennent
deja, consignees pour qu un remaniement ne puisse pas en laisser tomber
une en silence. Deux meritent la lecture, parce qu elles ont l air d etre
appliquees et ne le sont qu a moitie :

- le plafond de joueurs est un count() suivi d un add(). Deux inscriptions
  simultanees passent toutes les deux le compte et s inserent toutes les
  deux. Il n y a aucune contrainte derriere dans le schema (DB-005/006,
  bloques sur Alembic). Le test enonce la regle pour une requete a la
  fois, et dit que le cas concurrent n est pas couvert ;
- is_ended ferme sur end_date, avec repli sur date, et la comparaison est
  un < : une selection est encore ouverte le jour meme. C est un choix, et
  il est facile de l inverser par accident.

Le test du plafond a failli passer a vide : une connexion ratee laisse
elle aussi exactement une inscription. Il verifie donc que le second
joueur est bien entre, et un test temoin montre que sans plafond il
s inscrit. C est le piege consigne en vague E — un test qui remplace ou
suppose quelque chose doit verifier que ce quelque chose a eu lieu.

501 tests.
2026-08-11 13:39:57 -04:00
GGThed c5e5cfa014 refactor(validation): un schema aux frontieres tryout et evaluation
ARCH-005, seconde moitie. Meme forme que pour les matchs : des champs lus
a la main sur request.form, deux verifies et le reste cru sur parole.

Cote tryout :
- game pilote la liste des postes et les champs de gamertag montres au
  joueur qui s inscrit. Il etait accepte tel quel : une faute de frappe
  produisait une selection pour laquelle personne ne pouvait etre evalue ;
- max_players etait int(x) if x else None — un 500 sur « twelve », et un
  -3 accepte sans broncher ;
- coach_ids etait charge par User.id.in_(...) sans filtre de role. Une
  soumission fabriquee a la main pouvait donc nommer un joueur coach d une
  selection, ce qui est une attribution de droits : gerer la selection et
  evaluer ses joueurs. Ce n est pas un formulaire que l interface propose,
  et ca marchait.

Cote evaluation, validate_score transformait tout ce qui sortait de 1..10
— 11, 0, « bien » — en None. Le critere disparaissait de la moyenne et la
page annoncait l evaluation enregistree. Rien ne distinguait « non
evalue » de « evalue, refuse, et oublie ».

Le calcul de la moyenne remonte sur le modele, en Evaluation.overall_from
et apply_scores. Il vivait dans la route, additionnant neuf variables
locales, et ne pouvait pas etre exerce sans requete HTTP, session
authentifiee et base — c est TEST-002, et c est pourquoi le calcul des
scores n avait aucun test. Il en a maintenant six, sans rien monter.

Une precision qui compte : aucun critere rempli donne None, pas 0. La
grille commence a 1, donc un zero serait une note qu aucun joueur ne peut
recevoir, et qui le classerait sous tout le monde dans la liste.

Les neuf criteres sont ecrits en toutes lettres dans le schema plutot que
generes depuis le modele — un schema se lit — et un test verifie que les
deux listes coincident. C est la garde qui empeche la derive, pas
l astuce.

Douze chaines traduites, dont trois que pybabel avait devinees en fuzzy :
une entree fuzzy est ignoree a l execution, le piege est consigne dans
docs/translations.md.

30 tests neufs. 477 au total.
2026-08-11 13:35:05 -04:00
cedrick2711 5865df400f changé la photo du site pour le logo UdeS 2026-08-11 13:32:27 -04:00
GGThed 0308eb9eef refactor(validation): un schema a la frontiere des matchs
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.
2026-08-11 13:09:00 -04:00
GGThed d8541678a6 fix(auth): retirer un CAPTCHA qui ne protegeait rien, filtrer autrement
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.
2026-08-11 12:14:50 -04:00
GGThed d0a9e75fe6 perf: servir les statiques par nginx, et dire ce que le bot n a pas livre
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.
2026-08-11 11:56:48 -04:00
GGThed 47ff544848 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.
2026-08-11 11:40:41 -04:00
cedrick2711 9744e0b8c3 cache local dans le register page 2026-08-10 20:06:58 -04:00
cedrick2711 d007900f6c régler problème de dispos des coachs 2026-08-10 17:20:07 -04:00
GGThedandClaude Opus 5 ab0b975211 fix(arch): l inscription est une operation, donc une transaction
ARCH-006, avec une requalification du constat.

**Le chiffre de l audit surestimait le probleme.** « 58 commit() en routes
pour un seul rollback() » lisait un ratio comme un defaut. Mesure plutot
que suppose :

  - Flask-SQLAlchemy demonte la session a la fin de chaque requete, ce qui
    annule tout ce qui n a pas ete commite ;
  - l unique rollback est dans le gestionnaire 500, c est-a-dire au bon
    endroit ;
  - depuis la vague D, aucun module de routes ne contient `except
    Exception` : les 34 releves sont dans discord_bot.py, les scripts et le
    service de notification, ou avaler l erreur est le comportement voulu
    et documente.

Ce que le decompte ne pouvait pas voir, c est le vrai defaut : une fonction
qui commite DEUX fois, ou un echec apres le premier commit laisse une
demi-operation persistee. Il y en avait deux dans tout le depot -- une
analyse AST le confirme. edit_user etait la grave, corrigee avec ARCH-008.

register est la seconde : le compte etait commite, puis les gamertags dans
une seconde transaction. Un echec entre les deux laissait un compte dont
les jeux declares etaient absents, l inscription etant annoncee reussie.
Le premier commit devient un flush -- l identifiant est necessaire pour les
lignes suivantes, pas la durabilite.

Les deux commit() de login ne sont pas concernes : ils sont dans des
branches mutuellement exclusives, succes et echec.

tests/test_transactions.py enonce la garantie plutot que de la supposer :
un echec en cours de requete ne laisse aucune ligne, et l inscription est
tout ou rien. Le second echoue sur le code d avant.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 18:35:38 -04:00
GGThedandClaude Opus 5 aebc28fb8a docs: une seule source d architecture, et un README qui dit vrai
DOC-002.

docs/architecture.html (746 l.) et docs/architecture-v3.html (798 l.)
coexistaient, en HTML versionne, sans rien qui indique laquelle faisait foi
ni ou etait passee la v2. docs/architecture.md reprend la v3 -- la plus
complete : 24 classes au lieu de 19, plus la structure des paquets -- en
Markdown avec les diagrammes en Mermaid. Lisible en revue, comparable en
diff, rendu directement par Gitea comme par GitHub.

Ajoute au document ce que les diagrammes ne montrent pas et qu il faut
savoir avant de les lire : la double modelisation coach/equipe, l absence
de migrations, et le bot dans le meme processus que le web.

README
Il annoncait « Authorization Checks: Proper ownership validation on all
sensitive operations » a une epoque ou trois IDOR etaient ouverts, et
« Rate Limiting » sans mentionner que trusted_proxy='*' la rend
contournable. Il ne disait pas non plus comment installer, lancer ni
tester le projet.

Reecrit en francais, avec deux sections distinctes : ce qui est en place et
verifie par des tests, et **ce qui ne l est pas** -- migrations absentes,
secrets non revoques, proxy de confiance, identite Discord non prouvee.
Une liste de securite qui ne mentionne que les bonnes nouvelles est pire
qu absente : on s y fie.

Instructions d installation, de lancement et de verification ajoutees, avec
les deux seuls points d entree (ARCH-007).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 18:25:20 -04:00
GGThedandClaude Opus 5 e5c29d8113 fix(ops): rendre visibles les pannes silencieuses du bot Discord
OPS-005, OPS-006, OPS-007, OPS-009 et OPS-012. Cinq constats, un motif
commun : le bot pouvait cesser de fonctionner correctement sans que rien,
nulle part, ne le dise.

OPS-006 -- etat en attente ecrit en place
_save_pending ouvrait le fichier de destination en ecriture puis
serialisait dedans : toute interruption laissait un JSON tronque. Et
_load_pending interceptait l erreur de lecture, la journalisait, puis
repartait avec un dictionnaire vide -- toutes les correspondances message
Discord <-> demande disparaissaient, les reactions en cours cessaient
d avoir un effet, et l interface n en montrait rien.

Ecriture par fichier temporaire voisin puis os.replace : la destination
contient l ancien contenu ou le nouveau, jamais la moitie d un des deux.
A la lecture, un fichier illisible est deplace en .corrupt-<horodatage>
plutot qu ecrase, et le message dit ce qui est perdu.

Ecart assume avec la recommandation d audit (« echouer bruyamment ») : le
bot demarre quand meme. Refuser de demarrer supprimerait toutes les
notifications au lieu de celles deja en vol.

OPS-007 -- fuite lente
Les entrees n etaient retirees qu apres reaction. Elles portent desormais
`created_at` et sont purgees au chargement au-dela de 30 jours. Une entree
sans horodatage est conservee : elle precede ce champ, la supprimer serait
deviner son age.

OPS-005 -- planificateur
`coalesce=True`, `misfire_grace_time=3600`, `max_instances=1`. Sans delai
de grace, un redemarrage a 18 h 05 perdait les rappels du jour sans trace ;
sans coalescence, un planificateur en retard envoie un rappel par
occurrence manquee, donc des messages en double.

OPS-009 -- controle d identite asymetrique
handle_one_on_one_approve et _reject comparent depuis toujours le compte
qui reagit au coach destinataire. Les deux gestionnaires de presence ne le
faisaient pas. Meme forme de message, meme risque, un seul verifiait :
c est l asymetrie qui etait le bug.

Au passage : confirmer sa presence a un tryout ecrivait un attribut qui n a
pas de colonne (DB-008, bloque sur Alembic). Le joueur lisait « confirme »
et rien n etait enregistre. Toujours vrai, mais desormais journalise en
warning avec l identifiant concerne.

OPS-012 -- etat du bot dans /health
Le bot tourne dans un fil demon du processus web. Quand ce fil meurt, le
site continue de servir des pages et plus aucune notification ne part.
/health expose maintenant configured / running / connected / pending.
Signale, pas fatal : un club sans rappels Discord est degrade, pas hors
service, et un 503 le sortirait du repartiteur de charge pour ca.

discord_pending.json passe hors suivi git. La regle d ignore etait en place
mais inerte. Consequence non relevee par l audit : le deploiement etant un
miroir de fichiers, chaque livraison ecrasait l etat vivant du serveur par
celui du depot.

13 tests, sans aucun appel a Discord.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 18:21:05 -04:00
GGThedandClaude Opus 5 2e10bbbd62 perf: view_tryout, une requete par lot au lieu d une par ligne
PERF-001, la page la plus consultee de l application. Quatre boucles
posaient une requete par ligne :

  User.query.get()          par inscription
  Evaluation.query          par joueur inscrit, pour savoir si ce coach
                            l avait deja evalue
  TeamMember.query          par equipe
  User.query.get()          par membre d equipe

Plus, sur chaque match de type player_vs_player, deux interrogations
supplementaires de la relation dynamique `participants` pour trier par
camp -- alors que la liste complete venait d etre chargee douze lignes plus
haut.

Toutes remplacees par un chargement groupe. Les evaluations de ce coach
sont deduites de la liste `evaluations` deja en memoire, pas redemandees.

Mesure, sur un tryout de 10 inscrits, 2 equipes et 1 match :
34 requetes avant, 12 apres. Le test fixe un budget de 25, volontairement
large -- il ne peut que baisser, et il echoue sur le code d avant.

_users_by_id() est le helper partage par les trois chargements ; une ligne
absente est simplement absente du dictionnaire, ce que faisait deja un
get() renvoyant None.

Un second test verifie que les dix joueurs apparaissent toujours sur la
page : une requete groupee qui perd des lignes est le risque reel ici, pas
l erreur bruyante.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 18:08:18 -04:00
GGThedandClaude Opus 5 9d0456c2fd perf: borner le calendrier et remplacer trois boucles par des requetes
PERF-002, PERF-003, PERF-004. Aucun changement de comportement : chaque
reecriture est accompagnee de tests qui enoncent la reponse attendue, pas
la methode.

PERF-002 -- /matches/api/events
Le flux parcourait `tryout.matches` pour chaque tryout visible -- pour un
president, tout l historique du club -- puis posait une requete
MatchParticipant PAR match pour savoir si la personne qui regarde y figure.
Le cout du calendrier croissait avec l historique, a chaque navigation.

FullCalendar envoie deja `start` et `end` sur une source d evenements de
type URL. Personne ne les lisait. La requete est desormais bornee, et les
participants de tous les matchs de la fenetre sont charges en une fois,
joueur compris. Des bornes illisibles sont ignorees plutot que refusees :
un calendrier qui en montre trop est un probleme de performance, un
calendrier qui renvoie 400 est une page blanche.

PERF-003 -- get_players_available_at_time
Chargeait tous les joueurs actifs, puis une requete PlayerDisponibility par
joueur, sur une colonne non indexee. Deux requetes desormais, quelle que
soit la taille du club. Mesure dans le test : 7 requetes pour 6 joueurs
avant, 2 apres.

PERF-004 -- decompte des evaluations en attente
Chargeait toutes les inscriptions du club et toutes les evaluations du
coach, construisait deux ensembles Python et les soustrayait -- deux
lectures de table entiere pour produire un entier. Un COUNT DISTINCT avec
NOT EXISTS.

Les tests couvrent ce que la reecriture aurait pu changer sans bruit : fin
de creneau exclusive, compte desactive exclu, joueur a cheval sur deux
creneaux compte une fois, evaluation d un autre coach qui ne libere pas la
ligne, double inscription comptee une fois (DB-006 n a pas encore atterri,
donc le cas existe).

PERF-001 (view_tryout) n est pas fait : c est le plus gros des quatre, il
touche la page la plus consultee et merite son propre lot.

23 tests ajoutes, 371 au total.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 18:00:36 -04:00
GGThedandClaude Opus 5 0cd9a186ee refactor(services): un seul endroit pour annoncer un match planifie
Seconde moitie d ARCH-003.

Les memes vingt lignes vivaient dans trois routes -- matches.create_match,
matches.edit_match et team_matches.create_match : formater la date et
l heure, puis parcourir deux listes paralleles pour apparier un joueur avec
la ligne de participation qu une reaction Discord doit pouvoir retrouver.

Trois copies, donc trois occasions de diverger. Elles avaient deja diverge :

  create_match  lisait les heures des variables qu il venait d analyser, et
                affichait 'TBD' des qu une des deux manquait ;
  edit_match    les relisait depuis la ligne enregistree et substituait
                l heure de debut a une heure de fin absente.

Un match avec une heure de debut et pas de fin annoncait donc une heure
dans une route et 'TBD' dans l autre, pour la meme donnee.
app/services/scheduling.py retient la regle la plus soigneuse des deux.

zip_participants() isole l appariement par index, qui n est correct que
tant que les deux listes sont construites en phase -- desormais un seul
endroit a relire au lieu de trois, et une liste plus courte donne None,
converti en reference vers le match lui-meme.

matches.py 711 -> 687 lignes, team_matches.py 347 -> 337.

9 tests sur le service seul, sans base ni requete.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 17:24:39 -04:00
GGThedandClaude Opus 5 5c87064a17 refactor(routes): decouper users.py en paquet, extraire les notifications
ARCH-004 et la moitie NotificationService d ARCH-003.

app/routes/users.py faisait 1 699 lignes et couvrait six sujets qui ne
partageaient rien d autre qu un prefixe d URL. Il devient un paquet :

  blueprint.py     l objet Blueprint, seul
  _shared.py       helpers de formulaire, gamertags, validation de PDF
  accounts.py      348 l.  liste, creation, edition, suppression, fiche
  availability.py  233 l.  disponibilites joueur et creneaux coach
  contracts.py     206 l.  depot, signature, telechargement
  notes.py         376 l.  notes d equipe et notes nominatives
  one_on_one.py    259 l.  demandes de seance individuelle
  profile.py       132 l.  profil de la personne connectee

Aucun fichier ne depasse 400 lignes -- le critere d acceptation de l audit.

**Un seul blueprint, pas six.** Les endpoints restent `users.*`. Les
renommer aurait touche 137 appels `url_for` dans les gabarits, pour un
benefice nul : l objectif est un fichier qu on peut lire, pas une carte
d URL a reapprendre. Les 30 endpoints sont identiques avant et apres,
verifie sur url_map.

send_discord_notification part dans app/services/notifications.py. Elle
tirait `requests`, `logging` et le bot Discord dans un module dont le sujet
est le traitement HTTP, et se trouvait coincee entre deux definitions de
route. Son `except Exception` est conserve et documente : une notification
qui n arrive pas ne doit pas annuler la transaction qu elle annoncait.

tests/test_route_map.py, nouveau : il parcourt gabarits et code, releve
tout endpoint nomme litteralement dans un url_for, et verifie qu il existe
dans la carte. C est le mode de defaillance de ce genre de decoupage --
pas une erreur a l import, mais un BuildError chez la premiere personne qui
ouvre la page concernee. Un test de garde verifie aussi que le scan trouve
quelque chose, sinon le reste serait vide de sens.

Piege rencontre, et corrige : test_role_change patchait
`app.routes.users.update_user_gamertags`. Apres le decoupage ce nom est un
reexport, pas celui qu accounts.py resout -- le patch aurait pu laisser la
route appeler la vraie fonction et le test passer sans rien verifier. Ici
monkeypatch a echoue bruyamment, mais la cible est desormais explicite et
le test enregistre que la doublure a bien ete appelee.

347 tests passent (263 + 84, dont 82 parametres par la carte des routes).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 17:14:26 -04:00
GGThedandClaude Opus 5 8e3865f557 chore(lint): elargir les regles ruff et rendre le format bloquant en CI
QUA-002, seconde moitie. Le depot etant formate, l elargissement porte sur
des defauts et non sur du brassage.

Ajoute a la selection : B (bugbear), C4, RET, SIM, UP. Le lot entier n a
produit que 24 signalements sur 76 fichiers -- le code etait plus propre
que l audit ne le craignait. Neuf corriges automatiquement, quinze a la
main.

SIM108 est ignore : forcer un ternaire se lit moins bien que le if/else
qu il remplace, au seul endroit ou il se declenche.

isort (I) n est PAS active. Il reordonnerait les imports de 48 fichiers,
soit une seconde passe de pur brassage juste apres le commit de formatage.
A faire, mais seul.

Deux vrais defauts trouves par les nouvelles regles
  - team_matches.edit_match faisait `except ValueError: pass` sur l heure de
    debut et l heure de fin, trois lignes sous un champ date qui, lui,
    signale et redirige. Une heure mal saisie etait donc acceptee par le
    formulaire, jetee, l ancienne valeur conservee -- et la page annoncait
    la reussite. Meme traitement que la date desormais.
  - backup.py levait BackupError depuis deux blocs `except` sans `from`,
    ce qui perdait la cause d origine dans la trace.

Ainsi que : un `return` explicite dans force_https, `%`-formatage remplace
dans log_auth_event (operations de chaine avant journalisation, pas des
gabarits de logger -- la redaction n est pas affectee), une compréhension
inutile, un `set(...)` en compréhension d ensemble, `open(..., 'r')`, une
variable de boucle inutilisee, et `contextlib.suppress` dans conftest.

CI : `ruff format --check` remplace le commentaire qui expliquait pourquoi
il etait absent.

263 tests passent. Les deux nouveaux messages sont traduits ; attention,
pybabel les avait apparies en `fuzzy` avec des entrees « date » existantes,
et une entree fuzzy est ignoree a l execution.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:59:40 -04:00
GGThedandClaude Opus 5 7cec18c139 style: formater le depot avec ruff format
QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun
changement de comportement, aucune ligne de logique touchee. 72 fichiers,
4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur
les commits voisins reste lisible.

`quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui
evite le brassage guillemets simples / doubles : le diff porte sur les
retours a la ligne, l indentation des appels longs et les virgules
finales, pas sur le style de chaine.

Verification : 263 tests passent avant et apres, ruff check propre.

L activation en CI arrive dans le commit suivant, separement, pour que ce
diff-ci ne contienne rien d autre.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:53:10 -04:00
GGThedandClaude Opus 5 2f40290f00 fix(deps): nommer le pilote PostgreSQL, sinon rien ne demarre
QUA-001, volet dialecte.

`postgresql://` ne veut pas dire "le pilote installe" : SQLAlchemy y lit
psycopg2 et importe ce module a la creation du moteur. requirements.txt
epingle psycopg 3 (`psycopg[binary]`) et pas psycopg2. Une installation
propre demarree sur cette URL leve donc

  ModuleNotFoundError: No module named 'psycopg2'

avant la premiere requete. Verifie dans le .venv du depot, et c est
exactement la forme que Render distribue -- celle que docs/deployment.md et
docs/database-restore.md donnaient en exemple.

normalise_database_url() nomme le pilote quand l URL n en nomme pas.
`postgres://` (alias hérite, abandonne par SQLAlchemy en 1.4) est traite de
meme. Une URL qui nomme deja son pilote est laissee telle quelle, y compris
`postgresql+psycopg2://` : un environnement qui a psycopg2 garde le choix.

La normalisation a lieu apres l application de la configuration passee en
argument, pour couvrir aussi les appels de test. backup.py n avait pas
besoin d etre touche : il retirait deja le suffixe +pilote.

Documentation alignee sur les trois fichiers qui donnaient l exemple, dont
docs/deployment.md qui proposait sqlite:/// pour DATABASE_URL alors que
create_app refuse de demarrer sans PostgreSQL.

Reste de QUA-001, dit franchement
  - les trois paquets parasites (dotenv, login, discord) ne sont plus dans
    requirements.txt : deja retires. psycopg est deja epingle.
  - la consolidation vers des groupes de dependances n est PAS faite. Le
    deploiement est un miroir de fichiers lftp sans etape de construction ;
    les groupes PEP 735 demandent pip >= 25.1 sur une machine dont on ne
    peut pas verifier la version d ici. A revoir avec OPS-011.

14 tests, dont trois qui prouvent que l echec est reel et non theorique.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:50:35 -04:00
GGThedandClaude Opus 5 51877b46a0 refactor(arch): un point d entree par usage, et retrait du code mort
ARCH-007.

Trois points d entree, trois configurations differentes
  python app/app.py   0.0.0.0:10000, debogueur desactive par defaut
  python run.py       127.0.0.2:5000, debogueur ACTIVE par defaut
  python wsgi.py      Waitress, production

Le bloc __main__ de app/app.py disparait : ce module expose la fabrique.
run.py reste le point d entree de developpement, wsgi.py celui de
production, et c est tout.

run.py passait FLASK_DEBUG a 'true' par defaut. Le debogueur Werkzeug
execute du code soumis depuis le navigateur ; un processus lance ainsi et
laisse joignable est un shell distant. Le defaut passe a 'false', avec
l avertissement reecrit pour dire ce que le mode implique reellement.
L hote devient 127.0.0.1 -- 127.0.0.2 est une boucle locale valide mais
inhabituelle -- et hote comme port sont surchargeables par DEV_HOST et
DEV_PORT.

Code mort retire
  - discord_bot.py, notify_player_about_one_on_one : jamais appelee, seule
    la variante _direct l est.
  - evaluations.py, branche else de list_evaluations : elle listait les
    evaluations recues, une vue de joueur, alors que les joueurs sont
    rediriges au debut de la fonction et que can_evaluate() est vrai pour
    les quatre roles restants. Inatteignable.

Les autres elements du constat sont deja resorbes : get_auth_logger est
appelee depuis log_auth_event (OBS-001), et ALLOWED_CONTRACT_EXTENSIONS /
ALLOWED_SIGNED_EXTENSIONS sont lues par pdf_upload_error (SEC-021).

wsgi.py n est pas touche : trusted_proxy et HOST attendent la reponse du
developpeur sur la topologie reelle (nginx sur la meme machine ou non).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:12:34 -04:00
GGThedandClaude Opus 5 20158a9e7a fix(security): verifier les fichiers televerses et retirer un intent Discord
SEC-021 -- rien ne validait le contrat signe
upload_signed_contract se contentait d un nom de fichier non vide.
ALLOWED_SIGNED_EXTENSIONS etait declaree juste a cote et jamais lue. Le
fichier atterrissait sur le disque sous un nom que download_signed_contract
sert ensuite : ce qu un joueur televerse est ce qu un gerant ouvre.

Le nom seul ne suffisait pas non plus cote upload_contract, qui verifiait
`.pdf` en fin de chaine -- payload.pdf ne dit rien des octets.

pdf_upload_error() couvre les deux routes : extension dans la liste, puis
signature %PDF- en tete de flux. Le flux est rembobine, l appelant
enregistre toujours le fichier entier.

OPS-014 -- intent Discord privilegie inutile
Le bot demandait GUILD_MEMBERS et ne s en servait pas : rien n enumere ni
ne recherche de membre de serveur, les personnes sont jointes par le
discord_user_id enregistre sur leur compte. Retire.

message_content reste : on_raw_reaction_add lit le texte de la reponse d un
coach pour consigner un motif de refus.

CI-003 et CI-005 sont deja appliques (permissions: contents: read,
checkout@v4, exclusions de deploiement). L epinglage par SHA des actions
n est pas fait : ce sont des actions GitHub de premiere partie, et
l epingler sans Dependabot echange une exposition contre une autre.

11 tests, dont deux verifient que signer un contrat marche toujours et
qu un autre joueur ne peut pas le faire.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:09:13 -04:00
GGThedandClaude Opus 5 835a394d3f fix(auth): enumeration, verrou, deconnexion en GET, redirection ouverte
Quatre constats de la liste des gains rapides, tous sur auth.py.

SEC-017 -- enumeration de comptes
Le formulaire repondait "il vous reste 3 tentative(s)" a un compte connu et
"verifiez le nom d utilisateur et le mot de passe" a un inconnu. Le decompte
lui-meme etait la fuite : la meme information, etalee sur cinq requetes.
S y ajoutait un ecart de temps de reponse, check_password n etant appele que
si la ligne existait -- scrypt est cher, l ecart est mesurable.

Un seul message pour tous les echecs, et la verification s execute
desormais sur les deux branches : contre un hachage aleatoire tire une fois
par processus quand l identifiant n existe pas.

SEC-018 -- verrou de compte declenchable par un tiers
Cinq mauvaises reponses mettaient un compte connu hors service pendant
quinze minutes, indefiniment renouvelables. Sur un compte president, c est
toute l administration, et aucun ecran ne permettait de defaire.

Le compteur et la fenetre restent -- ce sont la trace qu un administrateur
lit quand un compte est pilonne, et la fenetre double jusqu a un plafond.
Ce qui change : de bons identifiants passent, fenetre ouverte ou non, et
remettent le compteur a zero. Le proprietaire du compte ne peut plus etre
bloque par un tiers.

Ce que cela coute, dit franchement : un verrou dur n arretait de toute
facon pas un attaquant ayant trouve le mot de passe -- il lui suffisait
d attendre. Le debit de tentatives reste borne par la limite de 10/minute
par IP. Une limite par couple (compte, IP) demanderait un stockage dedie ;
elle attend Alembic.

L evenement account.locked devient account.throttled : "locked" affirmait
plus que ce qui se passe.

SEC-019 -- deconnexion en GET
/auth/logout n avait pas de methods, donc GET, donc hors protection CSRF :
n importe quelle page pouvait deconnecter un visiteur avec une balise img.
La route passe en POST et l entree de navigation devient un formulaire avec
jeton. Le style suit -- les regles .nav-links visaient les liens seuls.

SEC-020 -- validation de redirection
is_safe_url interrogeait urlparse().netloc. urlparse lit /\evil.com comme
un chemin, sans netloc ; plusieurs navigateurs normalisent l antislash en
barre oblique avant de resoudre, ce qui en fait //evil.com. La fonction
refuse maintenant antislash et caracteres de controle, exige un chemin
enracine, et compare l origine explicitement.

Le xfail(strict) qui documentait SEC-017 est leve. 40 tests dans
test_auth_session.py, dont la table des cibles refusees.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:05:13 -04:00
GGThedandClaude Opus 5 d85da32ef5 fix(arch): changement de role sans jeter la session, et en une transaction
ARCH-008. `role` est le discriminateur polymorphe, et SQLAlchemy fixe la
classe d une instance au chargement. edit_user ecrivait donc la colonne par
un UPDATE de niveau instruction puis relisait la ligne -- c est correct.
La suite ne l etait pas.

  db.session.commit()
  db.session.remove()   # discard stale session entirely
  user = User.query.get(user_id_local)

Deux defauts dans ces trois lignes.

1. remove() jette la session entiere. Tout ce que la requete tenait encore
   se retrouvait detache, current_user compris ; le moindre acces a un
   attribut ensuite levait DetachedInstanceError. La route ne survivait
   qu en ayant recopie le nom et l id de l acteur dans des variables
   locales avant -- un contournement, pas la correction. Un expunge de la
   seule instance perimee suffit.

2. Le commit intermediaire coupait l edition en deux. Le role etait acquis
   avant que le reste du formulaire soit applique : une erreur ensuite
   laissait un compte promu et le reste perdu, sans qu aucune interface ne
   le signale. Sans ce commit, l UPDATE reste dans la transaction, la
   relecture le voit, et l ensemble part en un seul commit.

Les evenements d audit passent apres le commit. account.role_changed etait
journalise avant l UPDATE : le journal affirmait un changement que la
transaction pouvait encore annuler.

tests/test_role_change.py, 6 tests. Un seul echoue sur le code d avant --
celui de l atomicite ; les cinq autres fixent le comportement qui marchait
deja, pour que la suite de la vague D ne le casse pas.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 14:51:41 -04:00
GGThedandClaude Opus 5 92d72e4d48 refactor(authz): un seul point de verite pour les autorisations d equipe
ARCH-002. La question "a quelles equipes ce coach est-il rattache ?" etait
posee a huit endroits, de cinq facons differentes, et trois d entre elles
donnaient une mauvaise reponse en production.

Le motif fautif, present tel quel dans six routes :

    OrgTeam.query.filter_by(coach_id=user.id).first()

Il repond au plus une equipe, et seulement par la colonne heritee. Deux
pannes en decoulaient, silencieuses -- les pages s affichaient, vides :

  - un coach rattache uniquement par la relation many-to-many n avait
    aucune equipe, donc aucun joueur, aucun contrat, aucune note d equipe,
    aucun match a venir sur son tableau de bord ;
  - un coach de deux equipes n en voyait qu une. Le formulaire de contrat
    lui proposait la moitie de son effectif, alors que la route POST
    acceptait l autre moitie.

app/permissions.py devient le module ou la question se pose une fois :
coach_org_teams, manager_org_teams, attached_org_teams, visible_org_teams,
can_manage_org_team, org_team_player_ids, coach_player_ids,
can_manage_player_contract, coach_tryouts, coach_manages_tryout. Toutes
lisent les deux rattachements et toutes les equipes.

Le meme ecart existait dans le modele : Coach.get_visible_tryouts ne lisait
que la relation m2m -- calendrier vide pour un coach rattache par la
colonne -- et can_manage_this_tryout ignorait la colonne pour l equipe
cible. Les deux delegent desormais.

Corrections de portee, au passage
  - one_on_one lisait org_team.coach_id : un joueur dont l equipe declare
    ses coachs par la relation etait informe qu il n avait pas de coach, et
    le formulaire de demande restait ferme. Passe par get_coaches(), qui
    retombe deja sur la colonne heritee.
  - notes_dashboard conditionnait les notes personnelles du coach a
    l existence d une equipe : un coach sans equipe ne voyait pas ses
    propres notes.
  - can_manage_team_match reformulait can_manage_this_org_team ; la
    reformulation avait derive. Elle appelle maintenant la regle.

Limite assumee : le panneau de notes d equipe reste ecrit pour une seule
equipe et affiche donc la premiere. La resolution est corrigee, la mise en
page multi-equipes ne l est pas -- c est un choix produit, pas un bug.

14 tests ajoutes. Cinq echouent sur le code d avant, verifie en remettant
les routes et le modele a leur etat precedent.

ARCH-001 fera disparaitre la colonne heritee ; cela demande une migration
de donnees, donc Alembic. D ici la, ce module est ce qui rend la
duplication inoffensive.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 14:08:02 -04:00
GGThedandClaude Opus 5 37f70c89e3 feat(i18n): traduire les messages flash et de validation
198 appels flash dans les sept modules de routes, plus les 23 messages de
validation de app/validators.py. Le catalogue compte desormais 631 chaines,
aucune non traduite.

validators.py utilise lazy_gettext : les champs de schema sont construits a
l'import, donc avant qu'une requete existe. Un gettext ordinaire s'y
resoudrait une seule fois, dans la langue active au demarrage.

Un bug introduit par la conversion, puis corrige
  Le convertisseur automatique ne voyait que le premier litteral d'un appel
  flash, ce qui a casse deux chaines concatenees sur plusieurs lignes dans
  users.py -- le resultat n'etait meme pas du Python valide. Ma premiere
  verification ne l'a pas vu : elle enchainait py_compile sur head, or head
  reussit toujours, donc le "OK" s'affichait quoi qu'il arrive. Les deux
  appels sont reecrits et la verification refaite correctement.

Un bug plus interessant, revele par le test de fumee
  La langue choisie ne survivait pas a la connexion. login() et logout()
  appellent tous deux session.clear() -- l'un contre la fixation de session,
  l'autre pour terminer la session -- et le choix de langue partait avec le
  reste. Concretement : quelqu'un qui lisait la page de connexion en anglais
  se retrouvait en francais des qu'il se connectait.

  La langue est une preference d'affichage, pas un etat appartenant au
  compte. Les deux endroits la reportent maintenant explicitement, a cote du
  jeton CSRF. Quatre tests couvrent le cas, dont un qui verifie que corriger
  une cle preservee n'a pas fait tomber l'autre.

Detail de nommage : le convertisseur avait genere %(value)s pour une
expression conditionnelle, ce qui n'aide pas un traducteur. Renomme en
%(player)s.

Les 14 traductions ecrites avec une apostrophe droite sont normalisees en
apostrophe typographique. Sans consequence en HTML, ou &#39; s'affiche
correctement -- mais les blocs <script> ne decodent pas les entites, et
autant que le catalogue soit homogene.

200 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 22:12:27 -04:00
GGThedandClaude Opus 5 80bc1413f1 feat(i18n): traduire l'ensemble des gabarits
Le site s'affiche desormais integralement en francais. 483 chaines, aucune
non traduite, dans les deux catalogues.

Couvert : navigation et mise en page partagee, connexion, les cinq pages
d'erreur, et les 29 gabarits de pages.

Methode
  Marquage semi-automatique, conservateur par construction : seuls des
  motifs sans ambiguite sont balises -- contenu de balises de texte,
  attributs placeholder/title/aria-label, texte suivant une icone, blocs
  title et page_title -- et tout contenu comportant du Jinja ou du balisage
  imbrique est laisse de cote. Deux passes, la seconde pour td, li, h6,
  strong, em et caption.

  97 entrees etaient marquees fuzzy par pybabel update, c'est-a-dire
  devinees par similarite. Une entree fuzzy est **ignoree a l'execution** :
  elles ont donc ete traitees comme non traduites, et le drapeau retire une
  fois la traduction ecrite.

Deux pieges rencontres, tous deux documentes dans docs/translations.md

  Une entite HTML n'est pas du texte. &times;, utilise comme libelle de
  bouton de fermeture, a ete balise par la passe automatique. Jinja
  l'echappait alors en &amp;times; et le bouton aurait affiche le texte
  litteral &times; au lieu de la croix. Corrige dans cinq gabarits.

  Huit chaines subsistent dans des blocs <script>. Elles fonctionnent mais
  restent fragiles : Jinja echappe & < > " ' dans un bloc script, et ces
  entites n'y sont pas decodees -- une traduction contenant une apostrophe
  droite arriverait dans la chaine JavaScript sous la forme &#39;. Le
  francais retenu utilise des apostrophes typographiques, non echappees,
  donc l'existant est sur. Toute nouvelle chaine a cet endroit devra passer
  par un attribut data- ou un bloc <script type="application/json">.

Vocabulaire retenu -- a valider avec le club
  tryout -> selection · manager -> gerant · coach -> coach (conserve, terme
  d'usage en e-sport) · scout -> recruteur · email -> courriel · scrim ->
  scrim. Les noms de jeux et les postes (Support, Duelist, AWPer) restent
  en anglais : ce sont les termes employes par les joueurs.
  Ces choix vivent dans un seul catalogue, donc chacun se change en un
  endroit.

Reste a faire : les messages flash hors routes/auth.py, et les messages de
validation de app/validators.py, qui necessitent lazy_gettext puisque les
champs de schema sont construits a l'import.

Verifie : 13 pages parcourues dans les deux langues, aucune ne laisse de
marqueur Jinja non evalue ni d'entite doublement echappee. 193 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 21:43:29 -04:00
GGThedandClaude Opus 5 fcb58e8a17 feat(csp): retirer unsafe-inline de script-src
SEC-WEB-001 / OPS-010, ferme. C'est cette directive qui laissait s'executer
le XSS stocke de SEC-XSS-001 au lieu de le bloquer.

Les cinq derniers gabarits sont migres : match_form 13, calendar 11,
teams 11, evaluate_player 9, view_tryout 8. Total sur le chantier : 82
gestionnaires en ligne retires dans 17 gabarits. Il n'en reste aucun.

Deux motifs generiques de plus dans main.js
  data-mirror             affichage direct de la valeur d'un curseur.
                          evaluate_player repetait le meme
                          oninput="this.nextElementSibling.textContent = ..."
                          sur ses neuf curseurs de note.
  data-submit-on-change   remplace onchange="this.form.submit()"

Markup genere dans des chaines JavaScript
  match_form construisait sept gestionnaires par concatenation, en y
  injectant l'identifiant du joueur. Le markup portait deja data-player-id :
  returnToPool et assignToTeam lisent desormais leurs arguments depuis
  l'element clique. Cela supprime a la fois l'attribut en ligne et la
  concatenation qui l'alimentait. Meme motif que dans coach_availability.

Bascule
  CSP_ALLOW_INLINE_SCRIPT passe a false. script-src vaut maintenant
  'self' 'nonce-<aleatoire par requete>' https://cdn.jsdelivr.net.
  La variable d'environnement reste, comme issue de secours si un
  deploiement rencontrait un gestionnaire oublie -- mais la laisser active
  revient a renoncer a la protection.

Le cliquet devient une garde
  Le budget par gabarit est vide et les tests deviennent absolus : aucun
  gestionnaire en ligne, et tout bloc <script> inline doit porter son
  nonce. Sans nonce, un bloc n'est simplement pas execute, et rien dans les
  journaux ne le signale -- d'ou le test.

Verifications
  22 pages parcourues avec les trois roles : toutes rendent en 200, aucune
  ne contient de gestionnaire en ligne, et chaque bloc inline porte bien le
  nonce de sa propre reponse. Syntaxe JavaScript de chaque gabarit verifiee
  par node --check.

193 tests. Le dernier xfail de SEC-WEB-001 reussissait, le marqueur est
retire. Il n'en reste qu'un : SEC-AUTH-006, enumeration de comptes.

style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne sont pas un vecteur XSS a eux seuls. Migration distincte, non prioritaire.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 21:03:31 -04:00
GGThedandClaude Opus 5 09453199b8 refactor(csp): migrer dix gabarits vers les comportements declaratifs
OPS-010, suite. 76 gestionnaires en ligne -> 52, dans 5 gabarits au lieu de
15. Le cliquet de tests/test_csp.py est abaisse en consequence.

Six motifs recurrents, generalises dans main.js plutot que traites un a un
  data-action        clic, resolu par un ecouteur delegue
  data-change        changement -- attribut distinct du clic, sans quoi un
                     <select> declencherait son gestionnaire des le clic
                     qui l'ouvre
  data-confirm       confirmation avant un envoi destructeur, en
                     remplacement de onsubmit="return confirm(...)". Le
                     texte reste dans le markup, donc traduisible.
  data-navigate      navigation sur selection, {value} etant encode
  remove-element     suppression d'un ancetre designe par data-remove
  history-back       retour arriere

registerActions()
  Les fonctions propres a une page vivent dans son bloc de script et ne
  peuvent donc pas figurer dans la table globale. Chaque page declare les
  siennes, l'ecouteur delegue reste unique.

Cas particulier, coach_availability
  Le gestionnaire y etait construit dans une chaine JavaScript, au moment
  de generer la grille de creneaux. Le markup portait deja data-day et
  data-time : toggleSlot lit desormais ses arguments depuis l'element, ce
  qui supprime a la fois l'attribut en ligne et la concatenation.

Gabarits migres : my_teams, register, users, view_user, one_on_one,
coach_availability, profile, team_matches, notes, contracts.

Restent, par ordre decroissant : match_form 13, calendar 11, teams 11,
evaluate_player 9, view_tryout 8.

Syntaxe JavaScript de chaque bloc modifie verifiee par node --check.

A noter : la traduction de ces dix gabarits reste a faire. Seules les deux
chaines devenues visibles dans le markup au cours de cette migration -- les
messages de confirmation de suppression -- sont balisees et traduites.

192 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:51:07 -04:00
GGThedandClaude Opus 5 15bfebf4fc feat(csp): infrastructure de sortie de unsafe-inline, et couche partagee migree
SEC-WEB-001 / OPS-010. script-src porte toujours 'unsafe-inline' : c'est
pour cela que le XSS stocke de SEC-XSS-001 s'executait au lieu d'etre
bloque. Le retirer n'est pas un changement d'une ligne.

Ce qui bloque reellement
  Un nonce autorise des elements <script> ; il ne peut rien pour un
  attribut onclick="...". Mesure faite : 76 gestionnaires en ligne repartis
  dans 15 gabarits. Tant qu'il en reste un, la politique ne peut pas etre
  durcie.

  Piege supplementaire, documente dans build_csp() : en CSP niveau 3, un
  navigateur ignore 'unsafe-inline' des qu'un nonce est present. Emettre
  les deux ne serait donc pas une transition douce -- ce serait couper
  d'un coup tous les scripts en ligne et tous les onclick, et uniquement
  sur les navigateurs recents. La bascule doit etre atomique, d'ou un
  drapeau unique : CSP_ALLOW_INLINE_SCRIPT.

Infrastructure posee
  build_csp() assemble l'en-tete selon le drapeau. Un nonce est genere par
  requete et n'est emis que lorsque l'inline est interdit. Les 15 blocs
  <script> portent deja nonce="{{ csp_nonce }}", inerte aujourd'hui : la
  bascule finale sera un changement de configuration, pas de gabarits.

Couche partagee migree en premier
  base.html et macros.html sont rendus sur absolument toutes les pages. Six
  gestionnaires retires, remplaces par des attributs data-action et un
  ecouteur delegue unique dans main.js. La delegation plutot qu'un
  ecouteur par widget : le contenu injecte dynamiquement herite du
  comportement sans re-attachement.

Un cliquet plutot qu'une promesse
  tests/test_csp.py fixe un budget par gabarit qui ne peut que baisser.
  Ajouter un gestionnaire en ligne fait echouer la suite ; en retirer sans
  mettre le budget a jour aussi, ce qui force a enregistrer la progression
  dans le diff. A zero, il ne reste qu'a basculer le drapeau.

  Le cliquet a d'ailleurs corrige mon propre relevé : mon grep initial
  comptait 83 gestionnaires, la mesure exacte en donne 76 -- le motif ne
  verifiait pas l'espace avant l'attribut.

style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne constituent pas un vecteur XSS a eux seuls. Migration distincte.

192 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:44:16 -04:00
GGThedandClaude Opus 5 d15a3de2a1 fix(data): reparer les trois suppressions cassees
DATA-004, DATA-005, DATA-006. L'audit les classait "forte probabilite" faute
de pouvoir les executer. Les tests les confirment : ce sont des bugs averes,
declenchables par tout manager ou administrateur depuis l'interface.

Erreurs reellement obtenues avant correction :
  NOT NULL constraint failed: match_participants.match_id
  NOT NULL constraint failed: team_matches.org_team_id

Supprimer un match (DATA-004)
  Match.participants n'avait pas de cascade. SQLAlchemy tentait donc de
  detacher les participants en mettant match_id a NULL, ce que la colonne
  refuse. Tout match ayant eu des participants etait indestructible.
  TeamMatch.participants declarait deja delete-orphan ; Match non.

Supprimer un tryout (DATA-006)
  Les PersonalNote pointant vers ses matchs, equipes ou vers lui-meme
  n'etaient pas traitees.

Supprimer une equipe (DATA-005)
  TeamNote.org_team_id et TeamMatch.org_team_id sont NOT NULL et n'etaient
  pas traites du tout. De plus la fonction validait trois fois : un echec au
  troisieme temps laissait les tryouts detaches et les joueurs retires sans
  que l'equipe soit supprimee -- un etat incoherent que rien ne rattrapait.
  Une seule transaction desormais.

Regle appliquee, uniforme
  Ce qui n'a de sens que dans le parent est supprime avec lui : participants,
  membres, notes d'equipe, matchs de saison.
  Ce qui lui survit est seulement detache : les notes personnelles sont les
  observations d'un coach sur un joueur, pas des donnees de tryout. Les
  supprimer avec le tryout detruirait du contenu sans rapport. Idem pour les
  contrats et les demandes de rencontre individuelle.

Fidelite des tests
  conftest.py active PRAGMA foreign_keys=ON. SQLite ignore les cles
  etrangeres par defaut ; PostgreSQL les applique toujours. Sans ce reglage,
  la suite pouvait valider une suppression qui echoue en production --
  precisement la classe de bug corrigee ici. Les 146 tests passent avec les
  contraintes actives.

9 tests, dont trois qui verifient que les entites survivantes survivent
vraiment : une note garde son contenu et perd son contexte, un tryout
survit a l'equipe qu'il visait.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:37:04 -04:00
GGThedandClaude Opus 5 a92600c305 feat(i18n): francais comme langue principale, anglais accessible
Le site s'affiche desormais en francais par defaut, avec un selecteur de
langue permettant de basculer vers l'anglais.

Choix de conception : les chaines sources restent en anglais
  Elles servent d'identifiants gettext, et le francais est fourni par
  catalogue avec BABEL_DEFAULT_LOCALE = 'fr'. Le code reste ainsi dans une
  seule langue -- la meme que ses commentaires et docstrings -- tandis que
  ce qu'un membre voit est du francais.

  Consequence qui rend la migration praticable : une chaine non encore
  traduite retombe en anglais, pas sur un identifiant brut. Les gabarits
  peuvent donc etre migres un par un sans jamais laisser le site a moitie
  casse.

Selection de la langue (app/i18n.py)
  1. choix explicite via le selecteur, garde en session
  2. sinon en-tete Accept-Language du navigateur, restreint a fr et en
  3. sinon francais
  Un choix explicite prime toujours, y compris sur un navigateur anglophone.

Selecteur
  Extrait en partiel et inclus dans les deux branches de la mise en page :
  barre laterale une fois connecte, ET page d'authentification. Quelqu'un
  qui ne lit pas la langue courante doit pouvoir en changer AVANT de se
  connecter -- le laisser derriere l'authentification aurait ete un defaut
  d'accessibilite. Chaque langue est ecrite dans sa propre langue.

  La route /lang/<locale> valide le Referer avant de rediriger : sans ce
  controle, elle constituait une redirection ouverte.

Migre dans cette passe
  navigation complete, page de connexion, les cinq pages d'erreur, et
  l'integralite des messages flash de routes/auth.py. 64 chaines, dont
  aucune non traduite.

Verification
  25 tests, dont deux garde-fous d'integrite : un catalogue .mo manquant
  ou une entree non traduite font echouer la suite. Sans cela, une
  compilation oubliee servirait de l'anglais partout, en silence et sans
  rien dans les journaux.

Un test existant a du etre corrige, et c'est instructif
  test_login_failure_message_does_not_reveal_account_existence cherchait la
  sous-chaine anglaise 'attempt(s) remaining'. La page etant desormais en
  francais, elle etait absente des deux cotes, l'assertion passait, et le
  mode strict a signale le faux succes. Le test comparait donc l'anglais,
  pas le comportement. Il compare desormais les messages flash rendus,
  quelle que soit la langue. La faille SEC-AUTH-006 reste ouverte, et le
  test la documente toujours.

Les catalogues .po ET .mo sont versionnes : le deploiement est un simple
miroir de fichiers, sans etape de compilation. messages.pot, regenerable,
ne l'est pas.

docs/translations.md documente le processus, les deux pieges (concatenation
de phrases, traduction a l'import), et l'etat de la migration. A noter pour
la suite : les chaines dans les blocs <script> ne peuvent pas etre balisees
telles quelles, il faudra les passer par des attributs data- -- ce qui
rejoint le chantier de sortie de unsafe-inline (OPS-010).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:31:25 -04:00
GGThedandClaude Opus 5 afab7070fb fix(authz): une seule regle pour l'acces coach vers joueur
SEC-AUTHZ-004 et SEC-AUTHZ-005. La meme question -- ce coach peut-il agir
sur ce joueur ? -- recevait cinq reponses differentes selon la route :

  teams.py:add_player_note     verifiait l'appartenance via TeamPlayer
  users.py, 4 routes de notes  ne verifiaient rien au-dela d'isinstance
  contract.py:can_view         interrogeait la colonne heritee coach_id, et
                               traitait un team_id nul comme un joker

Consequences levees
  - tout coach pouvait ecrire une note nominative sur tout joueur du club.
    Ces notes sont visibles par le joueur concerne.
  - tout coach figurant dans OrgTeam.coach_id pouvait lire n'importe quel
    contrat sans equipe rattachee. Or upload_contract laisse team_id nul des
    que le joueur n'appartient a aucune equipe : la condition
    `not self.team_id or ...` ouvrait donc largement.
  - symetriquement, un coach rattache uniquement par la relation
    many-to-many ne voyait aucun contrat.

app/permissions.py
  Premier pas concret vers ARCH-002, sans refonte : un module unique, pas
  une couche de services. coach_org_team_ids() lit la relation m2m ET la
  colonne heritee, donc le deuxieme coach d'une equipe cesse d'etre
  invisible. coach_can_access_player() accorde l'acces si le joueur est sur
  une equipe du coach, ou inscrit a un tryout qu'il gere, ou participant a
  un match de ce tryout.

14 tests, dont deux verifient que les chemins legitimes fonctionnent
toujours : un coach note bien son propre joueur, et voit bien son contrat.

Note : la regle metier retenue -- equipe OU tryout -- est une lecture du
comportement existant, pas une decision produit. Si le club attend autre
chose, c'est desormais un seul endroit a changer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:17:49 -04:00
GGThedandClaude Opus 5 7bf428f9a0 fix(ops): sauvegarder reellement la base PostgreSQL
DATA-002 / OPS-001. backup.py ciblait SQLite : import sqlite3, DATABASE_PATH
par defaut instance/team_tryouts.db, et l'API de sauvegarde sqlite3. La
production tourne sur PostgreSQL, donc le fichier n'existait pas. Le script
affichait "[WARNING] Database not found... Skipping database backup" -- puis,
main() ne suivant que le resultat de la verification, **sortait avec le code
0**. Toute tache planifiee surveillant le code de sortie voyait vert alors
qu'aucune sauvegarde n'avait jamais ete produite.

Il n'existait donc aucune sauvegarde applicative de la base.

Reecriture
  pg_dump en --format=custom : compresse, et pg_restore permet une
  restauration selective, ce qu'un dump SQL a plat ne permet pas.
  parse_database_url accepte les suffixes de dialecte SQLAlchemy
  (postgresql+psycopg://) que pg_dump ne comprend pas, et refuse
  explicitement une URL SQLite -- le cas exact qui passait en silence.

  Le mot de passe ne figure jamais dans la ligne de commande : il serait
  visible de tout processus capable de lister argv. Il passe par PGPASSWORD.
  Il est egalement absent des messages affiches, qui atterrissent dans les
  journaux du planificateur.

  verify_backup lit l'archive avec pg_restore --list et exige au moins une
  table : une archive illisible ne se restaure pas, et une archive sans
  table signifie que le dump a vise la mauvaise cible. Les deux sont des
  echecs silencieux qu'il vaut mieux attraper maintenant que pendant un
  incident.

  Le code de sortie vaut 0 uniquement si le dump a ete produit ET verifie.

L'archive des documents est conservee : les contrats signes n'existent que
sur disque, la base ne stocke que des chemins. Restaurer l'une sans l'autre
laisse des lignes pointant vers des fichiers absents.

docs/database-restore.md
  Procedure de restauration testable sur une base jetable, requetes de
  controle, demarrage de l'application sur la copie restauree, plan de
  reprise par scenario. ENABLE_DISCORD_BOT=false y est signale comme non
  optionnel : sans lui, l'exercice demarre un vrai bot et envoie de vraies
  notifications a de vraies personnes, a partir de donnees restaurees.

  Les points ouverts sont listes tels quels : aucune copie hors site, pas de
  chiffrement au repos, aucune planification, et l'exercice de restauration
  n'a jamais ete effectue.

17 tests sur ce qui est verifiable sans serveur PostgreSQL : analyse de
l'URL, construction de la commande, non-fuite du mot de passe, et surtout
codes de sortie -- le silence ne vaut plus succes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:06:51 -04:00
GGThedandClaude Opus 5 ca45be80db fix(security): proteger le dernier administrateur, et fermer le CORS permissif
SEC-AUTHZ-007 - auto-verrouillage de l'administration
  Le changement de role n'excluait ni l'utilisateur courant, ni le dernier
  compte admin actif. Une seule manipulation suffisait a transformer le seul
  president en joueur, et plus aucune interface ne permettait de revenir en
  arriere : il fallait une intervention directe en base.

  Deux gardes distinctes, parce que ce sont deux erreurs differentes :
    - changer son propre role est refuse, meme s'il reste d'autres admins.
      Un president qui veut se retrograder doit le faire faire par un autre.
    - retrograder le dernier admin actif est refuse.
  Le decompte exclut les comptes desactives : trois admins dont deux
  desactives, cela fait un seul administrateur reel.

SEC-WEB-003 - CORS ouvert par defaut
  Sans CORS_ALLOWED_ORIGINS, la branche else appelait
  CORS(app, supports_credentials=True) sans argument origins. flask-cors
  retient alors '*' et, les identifiants etant autorises, renvoie en echo
  l'Origin de l'appelant avec Access-Control-Allow-Credentials: true --
  l'inverse exact de ce qu'annonçait le commentaire.

  L'exploitation etait bloquee par SESSION_COOKIE_SAMESITE = 'Lax', qui
  empeche le navigateur de joindre le cookie de session a une requete
  fetch inter-site. Toute la protection tenait donc a ce seul reglage.
  Cette application rend du HTML en meme origine : elle n'a besoin
  d'aucune politique CORS. La branche par defaut est supprimee, la
  configuration explicite reste possible.

5 tests ajoutes, dont deux verifient que les chemins legitimes continuent
de fonctionner : un autre administrateur reste retrogradable, et une
origine explicitement configuree est toujours honoree.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:03:50 -04:00
GGThedandClaude Opus 5 ab44258d72 feat(obs): journaliser les evenements d'authentification
OBS-001. logging_config.py configurait un fichier auth.log avec rotation,
un logger nomme 'team_tryouts.auth' et un filtre de redaction. Mais
get_auth_logger n'etait importe nulle part : le fichier etait cree et
restait vide. Aucune connexion, aucun echec, aucun verrouillage, aucun
changement de role, aucune suppression de compte ne laissait de trace.
En cas de suspicion de compromission, il n'y avait rien a consulter.

Ajout de log_auth_event(event, **fields), qui emet des paires cle=valeur
ordonnees -- greppable sans dependance de journalisation JSON.

Evenements couverts
  authentification  login.success, login.failure,
                    login.failure.unknown_user, login.rejected.locked,
                    login.rejected.deactivated, account.locked, logout,
                    account.registered
  administration    account.created_by_admin, account.updated,
                    account.role_changed (avec ancien et nouveau role),
                    account.deleted, account.password_reset_by_admin
  libre-service     account.password_changed

Le champ ip vient de request.remote_addr, donc de X-Forwarded-For. Tant que
Waitress tourne avec trusted_proxy='*' (SEC-WEB-002), cette valeur est
choisie par l'appelant : c'est une indication, pas une preuve. Le point est
documente dans la docstring.

Un test a fait remonter ARCH-003, jusqu'ici classe comme fragilite latente
  Journaliser en fin de edit_user levait DetachedInstanceError : le
  changement de role appelle db.session.remove() en plein cycle de requete,
  ce qui detache current_user de la session. Le code s'en tirait parce qu'il
  redirigeait immediatement sans plus y toucher. L'identite de l'acteur est
  desormais capturee en debut de traitement. Le constat est donc confirme
  comme reel, et non plus seulement probable -- sa correction de fond reste
  au programme.

15 tests, dont 4 sur le filtre de redaction lui-meme : c'est un controle de
securite, il doit etre verifie.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:01:24 -04:00
GGThedandClaude Opus 5 983b7a1f49 fix(auth): ajouter le parametre state au flux OAuth2 Discord
SEC-AUTH-004. L'URL d'autorisation ne portait que client_id, redirect_uri,
response_type et scope. Sans state, le callback acceptait n'importe quel
code d'autorisation qu'on lui presentait.

Scenario ferme : un attaquant obtient un code pour SON compte Discord, puis
fait charger l'URL de callback par le navigateur de la victime. Le
formulaire d'inscription de la victime se retrouve pre-rempli avec
l'identite Discord de l'attaquant. C'est le login CSRF decrit par la
RFC 6749 §10.12.

Mise en oeuvre
  secrets.token_urlsafe(32) genere le jeton, stocke en session avant la
  redirection. Le callback le compare en temps constant avec
  secrets.compare_digest, et le consomme systematiquement -- valide ou non --
  pour qu'il ne puisse pas etre rejoue. Le controle intervient avant
  l'echange du code : un callback rejete ne declenche aucun appel reseau.

Deux corrections accessoires sur le meme chemin
  - DISCORD_REDIRECT_URI est desormais verifie au meme titre que
    DISCORD_CLIENT_ID. Non defini, il faisait lever requests.utils.quote(None)
    au lieu de signaler un probleme de configuration.
  - la construction de la chaine de requete passe a urlencode() plutot qu'a
    une concatenation manuelle.

9 tests : presence du state, stockage en session, unicite entre deux
demandes, rejet sans state, avec un state forge, sans demande prealable,
et non-rejouabilite.

Reste ouvert : l'identite Discord obtenue reste ensuite reinjectee par un
champ cache du formulaire (SEC-AUTH-005). Le state protege la liaison, pas
encore la valeur elle-meme.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:01:24 -04:00
GGThedandClaude Opus 5 d05e9cde32 fix(security): supprimer le XSS stocke du calendrier
SEC-XSS-001. Chaine complete : un nom d'utilisateur libre arrivait dans le
DOM d'un coach ou d'un administrateur, en meme origine, avec sa session.
La CSP autorisant 'unsafe-inline', rien ne l'arretait.

Cote serveur - la cause
  /matches/api/events construisait de la presentation dans un champ JSON :
      match_desc = participants_str + f"<br>{match.description}"
  Le navigateur deposait cette valeur telle quelle dans innerHTML. Les noms
  de joueurs y transitaient sans echappement -- et il ne pouvait pas y en
  avoir : c'est du JSON, pas du HTML.

  Les deux valeurs etaient deja des cles distinctes du payload. La
  concatenation faisait donc aussi afficher les participants deux fois dans
  le modal : une fois dans "Teams", une fois en tete de "Description".
  Corriger la faille corrige l'affichage.

Cote navigateur - le sink
  showEventModal assemblait une chaine HTML puis l'affectait a innerHTML.
  Remplace par une construction de noeuds : makeEl / detailItem /
  multilineNode / teamNode passent tout texte par textContent. Les retours
  a la ligne d'une description restent rendus, via des <br> crees en dur.

  Les deux listes deroulantes concatenaient egalement titres de tryout et
  noms d'equipe dans innerHTML. Remplacees par new Option(), dont le
  premier argument est pose en texte.

Verification
  5 tests sur le contrat de l'API, dont un avec un nom d'utilisateur
  hostile ecrit directement en base -- ce que la validation refuse
  desormais, mais que des lignes anterieures peuvent contenir.
  Le JavaScript inline extrait passe `node --check`.

Reste ouvert : la CSP autorise toujours 'unsafe-inline' (SEC-WEB-001), donc
la defense en profondeur manque encore. Suivi en OPS-010.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 19:56:27 -04:00
GGThedandClaude Opus 5 32f193c008 style: f-strings sans placeholder signalees par ruff (F541)
Aucun changement de comportement. Les chaines concernees ne contenaient
aucune substitution.

A noter pour plus tard : run_https.py conserve une banniere en caracteres
semi-graphiques, du meme type que celle qui faisait planter security_scan.py
sur une console Windows en cp1252. Le script n'etant lance qu'en
developpement et de facon explicite, le point est signale sans etre corrige
ici.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 19:47:45 -04:00
GGThedandClaude Opus 5 5ecea55f55 fix(ci): rendre la chaine d'integration reellement verifiante
Les quatre jobs existaient ; aucun ne verifiait ce qu'il annoncait.

security-audit
  `pip-audit --require-hashes --no-deps || pip-audit`. L'etape
  d'installation ne posait que pip-audit, et aucune des deux formes ne
  nommait le fichier d'exigences : le repli auditait l'environnement du
  runner, qui ne contenait que pip-audit lui-meme. Le job passait au vert
  sans avoir examine une seule dependance de l'application. Remplace par
  `pip-audit -r requirements.txt`.

security-scan
  Appelait `python security_scan.py`, alors que le fichier se trouve dans
  app/supporting_scripts/. En echec a chaque execution depuis le
  deplacement du fichier. Trois autres defauts sont apparus en le faisant
  tourner :
    - la CI passe --skip-http, un argument que l'argparse du script
      n'acceptait pas : sortie en erreur 2 meme avec le bon chemin.
    - check_dependencies lisait data['dependencies'] comme la liste des
      vulnerabilites. Ce tableau liste en realite TOUTES les dependances,
      chacune portant un champ vulns vide si le paquet est sain. Les ~45
      paquets installes etaient donc signales vulnerables a chaque
      execution. Le filtrage se fait desormais sur vulns non vide.
    - check_flask_config interceptait son exception et renvoyait quand
      meme all_ok : ne pas reussir a charger l'application comptait comme
      un controle reussi. La section la plus importante du rapport n'avait
      jamais tourne. Elle renvoie desormais False, et l'import fonctionne
      grace a l'ajout de la racine du projet dans sys.path.
    - la banniere en caracteres semi-graphiques faisait planter le script
      sur une console Windows en cp1252, la plateforme meme du projet.
      Passee en ASCII.

lint
  Ruff n'avait aucun fichier de configuration : le job tournait sur le jeu
  de regles par defaut. La configuration vit maintenant dans pyproject.toml.
  `ruff format --check` est retire pour l'instant : la base n'ayant jamais
  ete formatee, il echouerait sur 62 fichiers sur 64 pour des raisons
  etrangeres a la correction. Reformatage puis application : QUA-002.

test
  Un `echo` protege par continue-on-error : le job annoncait un succes
  sans rien executer. Il lance desormais pytest avec couverture, et bloque.

permissions: contents: read au niveau du workflow, aucune etape n'ecrivant
dans le depot.

Deploiement Gitea
  actions/checkout@v7 n'existe pas (derniere majeure : v5) : le workflow
  echouait des sa premiere etape. Ramene a v4.
  Le miroir lftp poussait l'integralite de l'arbre de travail, dont
  clear_db.py -- un script qui vide toutes les tables et recree
  admin/password -- vers le noeud de production. Liste d'exclusions ajoutee.
  --delete reste volontairement absent : les contrats televerses, les
  journaux et le .env du serveur vivent sous la racine de deploiement et
  sont absents du depot ; les supprimer detruirait des donnees.

Le workflow de deploiement n'a pas pu etre execute depuis ici : la
syntaxe lftp reste a valider lors du prochain deploiement manuel.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 19:47:24 -04:00
GGThedandClaude Opus 5 a793b7ed0d fix(authz): verifier le rattachement equipe/tryout, et nettoyer le code mort
SEC-AUTHZ-002. add_to_team recevait tryout_id et team_id independamment
dans l'URL, controlait l'autorisation sur le tryout, puis operait sur
l'equipe sans jamais etablir de lien entre les deux. Un gestionnaire du
tryout A pouvait donc modifier une equipe du tryout B.

Le lint pointait exactement dessus : `team` etait charge ligne 463 puis
jamais utilise. La correction automatique proposee etait de supprimer la
variable, ce qui aurait fait taire l'avertissement en cimentant la faille.
Elle est desormais utilisee pour ce a quoi elle servait.

Trois defauts sur la meme route, corriges ensemble :
  - team.tryout_id != tryout_id repond maintenant 404
  - seuls les joueurs inscrits au tryout peuvent rejoindre ses equipes
  - int(player_id) sur une entree de formulaire brute levait ValueError,
    donc une erreur 500, sur toute valeur non numerique

Nettoyage automatique par ruff : 34 imports et variables morts retires
sur l'ensemble du paquet. La suite de tests a servi de filet, elle passe
a l'identique avant et apres. Aucun changement de comportement.

A noter, OneOnOneRequestSchema figurait aussi parmi les imports morts :
c'est un quatrieme schema jamais appele, la route one_on_one validant ses
dates a la main. Unifier la validation reste a faire (ARCH-005).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 19:47:00 -04:00
GGThedandClaude Opus 5 b277c453f9 fix(security): appliquer les schemas de validation sur la gestion des comptes
SEC-AUTHZ-001. CreateUserSchema, EditUserSchema et EditProfileSchema
etaient importes dans users.py et jamais appeles : chaque nom
n'apparaissait qu'une fois dans le fichier, sur sa ligne d'import. Les
trois routes lisaient request.form directement.

Consequences levees :
  - aucune politique de mot de passe sur create_user, edit_user et
    edit_profile. Un mot de passe d'un caractere etait accepte pour un
    compte administrateur.
  - aucune validation de format sur username, email, phone,
    discord_user_id.
  - edit_user ne verifiait pas l'unicite du courriel avant affectation :
    la contrainte unique remontait en IntegrityError, donc en erreur 500.
    Un controle explicite excluant l'utilisateur courant est ajoute.

C'est aussi le point d'injection de la chaine de XSS stocke SEC-XSS-001 :
edit_profile acceptait n'importe quel nom d'utilisateur, charge HTML
comprise, qui ressortait ensuite en JSON via /matches/api/events et
etait injectee par innerHTML dans le calendrier.

Deux details de formulaire imposaient un adaptateur, _form_payload :
  - request.form.to_dict() ne conserve que la premiere valeur d'une cle
    repetee, donc games doit etre relu avec getlist().
  - une case a cocher non cochee est absente de la soumission, ce qui
    n'est pas la meme chose qu'un load_default. Sans injection explicite,
    decocher is_active_account aurait cesse de desactiver le compte.
  - un mot de passe vide signifie "conserver l'actuel" et non "definir le
    mot de passe vide" : le champ est retire avant validation.

Le controle manuel du role devient redondant, le schema le contraint deja
par OneOf(USER_TYPES).

Verifie par quatre tests qui echouaient avant ce changement.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 19:47:00 -04:00
GGThedandClaude Opus 5 1b990a84d9 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 <[email protected]>
2026-08-07 19:46:34 -04:00
GGThedandClaude Opus 5 de9448a9aa fix(security,obs): fuite d'erreur sur /health, filtre nl2br, redaction des logs
/health divulguait le message brut du pilote
  L'endpoint n'est pas authentifie et renvoyait f'error: {str(e)}'. Les
  exceptions psycopg contiennent regulierement l'hote, le port, le nom de
  la base et l'utilisateur. Le detail part desormais dans les journaux,
  la reponse ne porte plus qu'un statut.

Filtre nl2br non echappant
  Markup('<br>'.join(...)) marquait le texte comme sur sans l'echapper.
  Le filtre n'etant utilise dans aucun gabarit, la faille etait latente :
  elle se serait ouverte au premier usage. Corrige en Markup('<br>').join(),
  qui echappe chaque segment. Verifie : nl2br('<script>alert(1)</script>')
  rend desormais &lt;script&gt;alert(1)&lt;/script&gt;.

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 <[email protected]>
2026-08-07 19:16:37 -04:00
GGThedandClaude Opus 5 2c37c05c8f fix(auth): rendre effectives l'expiration de session et la desactivation de compte
Deux protections etaient configurees sans avoir d'effet.

Expiration de session
  app.py:73 definit PERMANENT_SESSION_LIFETIME = 3600, mais Flask
  n'applique cette duree qu'aux sessions marquees permanentes. Aucune
  occurrence de session.permanent n'existait dans app/. Le cookie emis
  etait donc un cookie de session navigateur, sans expiration, et le
  serveur ne verifiait aucune anciennete. Ajout de session.permanent
  juste avant login_user, apres la rotation anti-fixation.

Desactivation de compte
  is_active_account n'etait consulte qu'au moment du login (auth.py:141).
  User n'ayant pas surcharge is_active, UserMixin renvoyait True en
  permanence. Desactiver un compte empechait donc la reconnexion mais
  laissait vivre la session en cours.

  La propriete is_active seule ne suffit pas : Flask-Login ne la consulte
  qu'a l'appel de login_user, jamais lors de la restauration d'une session
  depuis le cookie. Le verrou effectif est donc dans load_user, qui renvoie
  desormais None pour un compte desactive. La propriete est ajoutee malgre
  tout pour que login_user soit coherent avec le chargeur.

load_user passe au passage de Query.get() (API heritee, avertie en
SQLAlchemy 2.0) a db.session.get(), et tolere un identifiant non entier
sans lever.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 19:16:37 -04:00
GGThedandClaude Opus 5 56aee7f3f3 fix(deps): rendre requirements.txt installable sur Python 3.12
Trois problemes distincts empechaient une installation propre.

1. audioop-lts==0.2.2 declare Requires-Python >=3.13. Le fichier ayant
   ete produit par un pip freeze sur 3.13, la ligne etait inconditionnelle
   et faisait echouer `pip install -r requirements.txt` sur le 3.12 cible
   par la CI. Les jobs security-scan et test n'atteignaient donc jamais
   leur etape utile. Ajout du marqueur python_version >= "3.13" : le
   backport n'est tire que la ou audioop a quitte la bibliotheque standard.

2. Fichier encode en UTF-16 LE. pip sait le lire grace au BOM, mais
   l'outillage tiers non, et le diff est illisible. Reecrit en UTF-8, LF.

3. psycopg[binary] etait la seule dependance non epinglee. Epinglee a la
   version effectivement resolue (3.2.12).

Retrait de trois dependances parasites, verifiees non importees sur
l'ensemble de app/ et de la racine :
  - dotenv==0.9.9    doublon relais de python-dotenv (seul import reel)
  - discord==2.3.2   doublon relais de discord.py (seul import reel)
  - login==0.0.6     sans rapport avec Flask-Login, jamais reference

WTForms est conserve : c'est une dependance transitive legitime de
Flask-WTF (utilisee par CSRFProtect), et non une dependance directe morte.

Installation verifiee en environnement neuf sous Python 3.12.10.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 19:16:12 -04:00
GGThedandClaude Opus 5 90866f830e chore(gitignore): ignorer les artefacts d assistants IA et corriger la regle *.html
- Ajout des dossiers/fichiers des assistants IA (Claude, ChatGPT/OpenAI,
  Cursor, Copilot, Aider, Windsurf, Gemini, Continue, Cline).
- Ajout des notes de travail et de suivi generees par assistant (.ai/,
  audit/), retirees du suivi git via `git rm --cached` : les fichiers
  restent sur disque mais ne sont plus versionnes.
- Retrait des regles `docs/` et `*.html`, qui ignoraient TOUT fichier
  .html du depot, y compris les templates Jinja2. Un nouveau template
  etait invisible pour git : fonctionnel en local, TemplateNotFound en
  production, sans signal dans `git status`. Les rapports de couverture
  sont desormais couverts par htmlcov/ et coverage_html_report/.
- `.env` -> `.env*` avec exception `!.env.example`, pour couvrir
  .env.local, .env.production et les copies de sauvegarde.
- Ajout des cles et certificats, des sauvegardes (backups/), des
  journaux (logs/), des environnements virtuels, des caches d outils
  et des fichiers d editeur/OS.
- Deduplication des regles existantes (*.db et instance/ etaient en
  double, .instance/ n existait pas).

Aucune modification du code applicatif.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 13:27:13 -04:00
GGThedandClaude Opus 5 ed586233f6 docs(audit): rebase sur le depot de reference et reverification complete
Le miroir GitHub audite en premiere passe etait en retard de 16 commits
sur git.immortal.host/clubesportsudes/team-tryouts. L'audit est rebase
sur immortal/main @ bb0bc1c et l'ensemble des constats reverifie.

Resolus par l'equipe (archives dans 00-provenance.md) :
- seed automatique en production supprime
- print du token Discord supprime
- proxy_pass nginx corrige vers 127.0.0.1
- dossier supporting_scrits renomme

Nouveaux constats :
- SEC-20 flux OAuth2 Discord sans parametre state (CSRF de liaison)
- SEC-21 le deploiement SFTP pousse .git/ sur le serveur
- SEC-22 clear_db.py destructif sans garde-fou, admin/password en dur
- MNT-16 discord_pending.json versionne

Requalifies :
- SEC-01 secrets retires du fichier mais toujours dans l'historique des
  deux depots, et dans le HEAD du miroir GitHub -> revocation requise
- SEC-05 trusted_proxy='*' + bind 0.0.0.0 rend X-Forwarded-For usurpable,
  ce qui ouvre le rate limiting au lieu de le corriger
- SEC-07 l'OAuth2 ajoute ne contraint pas l'identite : le discord_user_id
  transite par un champ cache du formulaire
- MNT-05 psycopg[binary] non epingle, incompatible avec les URI
  postgresql:// que SQLAlchemy resout vers psycopg2

48 constats. Aucune modification du code applicatif.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 13:17:08 -04:00
GGThedandClaude Opus 5 fa0a378827 docs(audit): audit securite, maintenabilite et standards de la stack
Revue statique de l'ensemble du code Python, de la configuration CI/nginx,
du .gitignore et des dependances. 44 constats documentes avec references
fichier:ligne, impact et correctif propose.

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

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

Aucune modification du code applicatif.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 13:02:54 -04:00
cedrick2711 bb0bc1c192 ajout de plateforme de base pour les url TRN 2026-08-06 20:13:39 -04:00
cedrick2711 f89e4deb30 régler problème avec mise a jour des status de message du discord bot 2026-08-06 15:26:00 -04:00
cedrick2711 fcf10bcdff bug fix:
Manager ne pouvait pas voir les tryouts.
probleme avec discord bot
2026-08-06 14:46:51 -04:00
cedrick2711 68e3da6601 fix probleme avec dispos 2026-08-06 13:41:53 -04:00
cedrick2711 d25b35c928 régler erreur 500 sur changement de role par admin 2026-08-04 22:06:43 -04:00
cedrick2711 aeba4d78cf régler problème de changement de rôle 2026-08-04 21:52:52 -04:00
cedrick2711 7bb80229db added a clear_db to start fresh with only an admin 2026-08-04 13:09:19 -04:00
cedrick2711 0dd4ecdb4c Erreur de frappe dans un des dossiers 2026-08-04 12:57:18 -04:00
cedrick2711 0f7788e973 Changement du layout de la page d'enregistrement 2026-08-04 12:35:25 -04:00
cedrick2711 47d5ec47e7 added discord oauth2 to get basic user info to complete profile when registering 2026-08-04 12:25:03 -04:00
cedrick2711 10af8c0d22 small change for prod 2026-08-03 23:08:44 -04:00
cedrick2711 7d30aff986 Corriger erreur d'enregistrement 2026-08-03 21:51:23 -04:00
m3ow 409741642a Update wsgi.py 2026-08-03 16:43:46 -04:00
m3ow 056cea0635 Update .gitea/workflows/git-to-ptero.yaml 2026-08-02 19:46:16 -04:00
m3ow d3b5700977 Add .gitea/workflows/git-to-ptero.yaml 2026-08-02 18:39:08 -04:00
clubesportsudes fd258de8c9 Update app/.env.exemple
CI - Security & Lint / Security Audit (push) Has been cancelled
CI - Security & Lint / Lint with Ruff (push) Has been cancelled
CI - Security & Lint / Security Scan (push) Has been cancelled
CI - Security & Lint / Tests (push) Has been cancelled
2026-08-02 18:36:54 -04:00
cedrick2711 d6fe505628 Merge branch 'main' of https://github.com/cedrick2711/team-tryouts 2026-07-30 13:25:05 -04:00
cedrick2711 35c07c263d Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-30 13:25:02 -04:00
cedrick2711 251060ce58 Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts into dev 2026-07-30 13:24:46 -04:00
cedrick2711 51e6b81ac7 régler problème ou les coachs ne voyait pas leur tryouts 2026-07-30 13:24:43 -04:00
cedrick2711 7c375f45dd mise à jour du README 2026-07-30 09:52:32 -04:00
cedrick2711 2d3721b201 Ajout d'un .env.exemple pour simplifier la collaboration 2026-07-30 08:07:28 -04:00
cedrick2711 e629067434 Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-29 20:25:01 -04:00
cedrick2711 19c6740edb régler problème avec le bot discord et ajouter un panneau pour gérer les one on one (accepter ,refuser, confirmer) 2026-07-29 20:24:10 -04:00
cedrick2711 962e621fee régler problème ou on ne voyait pas les dates pour book un One on One 2026-07-29 17:17:11 -04:00
cedrick2711 5e7d7324de Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-29 17:01:41 -04:00
cedrick2711 ad7b932c0a régler les problèmes de route 2026-07-29 17:01:28 -04:00
cedrick2711 da38f90641 Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-29 15:59:45 -04:00
cedrick2711 1702139250 corriger erreur de nom 2026-07-29 15:58:40 -04:00
cedrick2711 b02316ed7c Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-29 15:55:36 -04:00
cedrick2711 3bfe9a99a5 ajustement pour la nouvelle db 2026-07-29 15:53:29 -04:00
cedrick2711 a5bdbefa89 rajout de db dans gitignore 2026-07-29 14:58:14 -04:00
cedrick2711 80ff12f51a rajout de la db dans gitignore 2026-07-29 14:57:01 -04:00
cedrick2711 1337d8e244 delete requirement 2026-07-29 14:49:01 -04:00
cedrick2711 f1b2641718 Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-29 14:48:52 -04:00
cedrick2711 98eecc5e87 ajustement du gitignore 2026-07-29 14:47:58 -04:00
cedrick2711 b80c5b7aa2 UML avec la derniere version du projet 2026-07-29 14:46:49 -04:00
cedrick2711 fc1bdc57c4 régler les imports des fichiers après redistribution 2026-07-29 14:16:01 -04:00
cedrick2711 b69eaabbba remodulation du projet en POO et changement de l'organisation des fichiers 2026-07-29 13:57:06 -04:00
cedrick2711 3feae80767 remodulation du projet et des classes 2026-07-28 23:33:31 -04:00
cedrick2711 90ac3eb804 Uml et fonctionnel dans un document .html 2026-07-28 18:41:05 -04:00
cedrick2711 a0f18a886c Correction des présences et correction de plusieurs erreurs mineur de déplacement/parcours de l'utilisateur, harmonisation des processus 2026-07-28 18:11:02 -04:00
cedrick2711 9b05c7e770 petit ajustement pour render 2026-07-28 15:52:17 -04:00
cedrick2711 4e9d93d8c8 Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-28 15:45:48 -04:00
cedrick2711 b982cd0318 ajout de match régulier pour les équipes et de pratiques
Ajout d'un profil public cliquable pour les utilisateurs
déplacement du profil
2026-07-28 12:39:53 -04:00
cedrick2711 1c97f3a181 changement d'IP pour Render 2026-07-27 13:00:21 -04:00
cedrick2711 f48e1104e7 Merge branch 'main' of https://github.com/cedrick2711/team-tryouts 2026-07-27 12:57:27 -04:00
cedrick2711 b0b898f1e8 changement de port pour Render 2026-07-27 12:57:24 -04:00
Cédrick Martel de99643dd9 Rename requirement.txt to requirements.txt 2026-07-27 12:50:54 -04:00
234 changed files with 33664 additions and 9368 deletions
+152
View File
@@ -0,0 +1,152 @@
name: Push to SFTP
on:
workflow_dispatch:
# push:
# branches:
# - main # Optional: Run automatically on pushes to the main branch
# OPS-011 — what this workflow now guarantees, and what it still does not.
#
# Guaranteed:
# - nothing is uploaded unless the test suite and the linters pass;
# - only files on an explicit allowlist are uploaded, so a new file at the
# repository root does not reach production by default. That is how
# clear_db.py — a script that DROPs every table and recreates
# admin/password — got there in the first place;
# - after the upload, /health is polled until it answers healthy, and the
# job fails loudly if it does not. Before, a half-uploaded tree was a
# green deployment.
#
# NOT guaranteed — the switch is not atomic. Files are mirrored in place, so
# for the length of the transfer production runs a mixture of two versions.
# Closing that needs a release-directory layout, which has three
# prerequisites, two of which cannot be done from here:
#
# 1. the Pterodactyl startup command must run the app from `current/`
# rather than from the server root, and the server must be restarted on
# switch — a panel change;
# 2. `documents/`, `logs/` and `.env` must live beside the releases, not
# inside one. DOCUMENTS_ROOT exists for this (app/storage.py);
# 3. contract paths must be relative to that root, so the switch does not
# strand them. Done: new rows are relative, old absolute ones still
# resolve.
#
# docs/deployment.md carries the design and the rollback procedure.
# Least privilege (CI-003). This job never writes back to the repository; it
# holds the SSH key to the production node, which makes it the most valuable
# job in either forge to compromise.
permissions:
contents: read
# Actions pinned to a commit, version in the comment. A tag is a moving
# pointer, and moving `v4` here means running arbitrary code in the job that
# holds that key. If the Gitea runner ever fails to resolve a commit ref, it
# fails on the checkout step — loudly, like the `@v7` that did not exist.
jobs:
deploy-to-sftp:
runs-on: ubuntu-latest
steps:
# Was @v7, which does not exist (latest major is v5): the workflow
# failed on its very first step.
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
# The gate. This runner is not the GitHub one, so a green CI over
# there proves nothing about what is about to be shipped from here:
# the deploy is triggered by hand, on whatever the branch holds.
- name: Refuse to deploy a broken tree
run: |
python -m pytest -q
python -m ruff check .
python -m ruff format --check .
# An allowlist, not a list of exclusions. The previous form mirrored
# the whole working tree minus nine globs, so every file added to the
# repository shipped to production unless someone remembered to
# exclude it. This inverts the default: a new top-level file has to be
# named here to reach the server.
- name: Assemble the release payload
run: |
set -euo pipefail
mkdir -p payload
cp -r app payload/
cp requirements.txt wsgi.py payload/
# Compiled catalogues are versioned deliberately: the deployment is
# a file mirror with no build step (docs/translations.md).
find payload -name '__pycache__' -type d -prune -exec rm -rf {} +
find payload -name '*.pyc' -delete
echo "Shipping $(find payload -type f | wc -l) files:"
find payload -maxdepth 2 -type d | sort
- name: Set up SSH Private Key
env:
# Binds the secret to a secure environment variable
SSH_PRIVATE_KEY: ${{ secrets.SSH }}
run: |
mkdir -p ~/.ssh
# Uses the environment variable, so the raw key is never printed in the execution log
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Push files via SFTP with progress
run: |
# --delete is deliberately NOT used. Uploaded contracts, logs and the
# server's own .env live under the deployment root and are absent
# from the repository; deleting anything not present locally would
# destroy them. Stale files therefore still accumulate — that is the
# other half of what the release-directory layout would fix.
lftp -e "set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes -o PasswordAuthentication=no'; \
set sftp:auto-confirm yes; \
set net:max-retries 5; \
set net:timeout 30; \
set cmd:fail-exit yes; \
open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \
mirror -R --verbose --parallel=4 ./payload/ ./; \
quit"
# Without this a deployment that left the site 500ing reported success,
# and the first person to hear about it was a user. /health checks the
# database connection and reports whether the Discord bot thread is
# alive (OPS-012).
# HEALTH_URL is carried as a secret rather than as a variable. It is
# not secret — it is the public site — but `secrets` is the context
# this runner is already known to support, and a smoke test that fails
# to run because of an unsupported expression is worse than none.
- name: Smoke test
if: ${{ secrets.HEALTH_URL != '' }}
env:
HEALTH_URL: ${{ secrets.HEALTH_URL }}
run: |
set -euo pipefail
# The app is restarted by the panel, not by this workflow, so the
# first few probes are expected to fail or answer from the old
# process. Two minutes, then give up.
for attempt in $(seq 1 24); do
body=$(curl -fsS --max-time 10 "$HEALTH_URL" 2>/dev/null) || body=''
if echo "$body" | grep -q '"status": *"healthy"'; then
echo "Healthy after ${attempt} attempt(s):"
echo "$body"
exit 0
fi
echo "attempt ${attempt}: not healthy yet"
sleep 5
done
echo "::error::/health never reported healthy. The deployment is live and may be broken — see the rollback procedure in docs/deployment.md."
exit 1
- name: Warn when no health check is configured
if: ${{ secrets.HEALTH_URL == '' }}
run: |
echo "::warning::HEALTH_URL is not set, so this deployment was not verified. Set it to https://<host>/health in the repository secrets."
+52 -25
View File
@@ -11,84 +11,111 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
# Least privilege: nothing here writes back to the repository.
permissions:
contents: read
# Third-party actions are pinned to a commit, with the version in a comment
# (CI-003). A tag is a moving pointer: whoever can move `v4` runs code in a
# job that holds this repository's token. The comment is what makes the pin
# maintainable — a bare 40-character hash tells a reader nothing about
# whether it is current. Dependabot updates both together.
env:
PYTHON_VERSION: '3.12'
jobs: jobs:
security-audit: security-audit:
name: Security Audit name: Security Audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.12' python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip' cache: 'pip'
- name: Install dependencies - name: Install pip-audit
run: pip install pip-audit run: pip install pip-audit==2.9.0
- name: Scan for vulnerable dependencies # Previously: `pip-audit --require-hashes --no-deps || pip-audit`.
run: pip-audit --require-hashes --no-deps || pip-audit # Neither form named the requirements file, so the fallback audited the
# runner's environment — which contained pip-audit and nothing else.
# The job passed green while checking none of the application's
# dependencies. -r makes it audit what the application actually pins.
- name: Scan declared dependencies for known vulnerabilities
run: pip-audit -r requirements.txt
lint: lint:
name: Lint with Ruff name: Lint with Ruff
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.12' python-version: ${{ env.PYTHON_VERSION }}
- name: Install ruff - name: Install ruff
run: pip install ruff run: pip install ruff==0.14.4
# Rule selection and per-file ignores live in pyproject.toml. Before it
# existed, this step ran ruff's bare defaults with no configuration.
- name: Run ruff linter - name: Run ruff linter
run: ruff check . --output-format=github run: ruff check . --output-format=github
- name: Run ruff formatter check # Enabled now that the repository has been formatted once, in its own
# commit (QUA-002). Reaching this step before that would have failed on
# 72 of 76 files for reasons unrelated to correctness.
- name: Check formatting
run: ruff format --check . run: ruff format --check .
security-scan: security-scan:
name: Security Scan name: Security Scan
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.12' python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip' cache: 'pip'
- name: Install app dependencies - name: Install app dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt -r requirements-dev.txt
# The path was `security_scan.py`, but the script lives under
# app/supporting_scripts/. The step had therefore failed on every run
# since the file was moved.
- name: Run security scan - name: Run security scan
env: env:
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }} SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
FLASK_DEBUG: 'false' FLASK_DEBUG: 'false'
run: python security_scan.py --skip-http run: python app/supporting_scripts/security_scan.py --skip-http
test: test:
name: Tests name: Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [security-audit, lint] needs: [security-audit, lint]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.12' python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip' cache: 'pip'
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt -r requirements-dev.txt
# Previously an `echo` guarded by continue-on-error: the job reported
# success without executing anything. The suite needs no environment
# variables and no database server: create_app() takes its configuration
# as an argument and the fixtures use a temporary SQLite file.
- name: Run tests - name: Run tests
run: | run: pytest --cov=app --cov-report=term-missing --cov-report=xml
echo "No tests configured yet. Add tests to the project."
# python -m pytest tests/ --cov=. --cov-report=xml
continue-on-error: true
+143 -13
View File
@@ -1,21 +1,151 @@
.env # =============================================================================
# Secrets et configuration locale
# =============================================================================
# `.env*` couvre .env, .env.local, .env.production, .env.bak…
# L'exception laisse passer le seul fichier modèle, qui ne doit contenir
# que des valeurs factices.
.env*
!.env.example
.instance/ *.key
*.pem
*.pfx
*.p12
.certs/
id_rsa
id_ed25519
known_hosts
credentials.json
service-account*.json
# =============================================================================
# Assistants IA — Claude
# =============================================================================
.claude/
.claude.json
.claude/settings.local.json
CLAUDE.md
CLAUDE.local.md
.anthropic/
# =============================================================================
# Assistants IA — ChatGPT / OpenAI
# =============================================================================
.chatgpt/
.openai/
.codex/
AGENTS.md
chatgpt-*.md
openai-*.md
# =============================================================================
# Assistants IA — autres outils
# =============================================================================
.cursor/
.cursorrules
.cursorignore
.windsurf/
.windsurfrules
.aider*
.continue/
.clinerules
.roo/
.gemini/
GEMINI.md
.github/copilot-instructions.md
.copilot/
# =============================================================================
# Notes de travail et suivi générés par assistant IA
# =============================================================================
# Rapports d'audit, plans, brouillons et notes de session : documents de
# travail, non destinés à être versionnés dans le dépôt applicatif.
.ai/
audit/
*.audit.md
NOTES-IA.md
TODO-IA.md
# =============================================================================
# Python
# =============================================================================
__pycache__/
*.py[cod]
*.so
*.egg-info/
.eggs/
build/
dist/
# Environnements virtuels
venv/
.venv/
env/
ENV/
# Outils
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
.coverage.*
htmlcov/
coverage_html_report/
coverage.xml
# =============================================================================
# Données applicatives — ne jamais versionner
# =============================================================================
# Base SQLite locale (une base de démo a déjà été committée par le passé,
# voir l'historique du commit `4fd27ba`).
instance/
*.db *.db
*.sqlite
*.sqlite3
# Contrats téléversés (données personnelles)
documents/ documents/
__pycache__/ # Sauvegardes produites par app/supporting_scripts/backup.py
*.cpython-313.pyc backups/
*.cpython-312.pyc
*.pyc
.pytest_cache/ # Journaux applicatifs
.coverage logs/
htmlcov/
.DS_Store
*.log *.log
.certs/ # État d'exécution du bot Discord
*.pem discord_pending.json
# =============================================================================
# Éditeurs et systèmes d'exploitation
# =============================================================================
.idea/
.vscode/
*.swp
*.swo
.DS_Store
Thumbs.db
desktop.ini
# =============================================================================
# NOTE — règles retirées volontairement
# =============================================================================
# `docs/` et `*.html` figuraient ici et ignoraient TOUT fichier .html du
# dépôt, y compris les templates Jinja2 de app/templates/. Un nouveau
# template était donc invisible pour git : l'application fonctionnait en
# local et cassait en production avec une TemplateNotFound, sans que
# `git status` ne signale quoi que ce soit.
#
# Les rapports de couverture HTML, qui étaient vraisemblablement la cible
# de ces règles, sont couverts ci-dessus par `htmlcov/` et
# `coverage_html_report/`.
# =============================================================================
# Internationalisation
# =============================================================================
# Le gabarit de catalogue est entierement regenerable :
# pybabel extract -F babel.cfg -k _l -o messages.pot .
# Les catalogues .po (sources de traduction) et .mo (compiles, lus a
# l'execution) sont eux versionnes : le deploiement est un simple miroir de
# fichiers, sans etape de compilation.
messages.pot
+134 -60
View File
@@ -1,79 +1,153 @@
### Plateforme centralisée de tryouts # Plateforme centralisée de tryouts
## Security Configuration Application interne du club e-sport de l'UdeS : inscriptions aux sélections,
évaluations, gestion des équipes, disponibilités, contrats, et notifications
Discord.
### Required Environment Variables Le site est servi **en français**, l'anglais reste accessible par le sélecteur
de la barre latérale (voir `docs/translations.md`).
Before deploying, create a `.env` file with the following: ---
``` ## Démarrer
# Flask Configuration (REQUIRED)
SECRET_KEY=your-secure-random-secret-key-here
# Production Settings ```bash
FLASK_DEBUG=false python -m venv .venv
FORCE_HTTPS=true .venv/Scripts/pip install -r requirements.txt -r requirements-dev.txt
SESSION_COOKIE_SECURE=true cp app/.env.example .env # puis remplir SECRET_KEY et DATABASE_URL
.venv/Scripts/python run.py # développement, http://127.0.0.1:5000
``` ```
### Security Features Implemented `SECRET_KEY` et `DATABASE_URL` sont **obligatoires** : `create_app()` refuse
de démarrer sans eux. `DATABASE_URL` doit pointer sur PostgreSQL ; le pilote
psycopg 3 est nommé automatiquement si l'URL n'en nomme pas.
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks Production : `python wsgi.py` (Waitress derrière nginx). Voir
- **Secure Session Cookies**: HTTPOnly, SameSite=Lax, and Secure flags enabled `docs/deployment.md`.
- **CSRF Protection**: Enabled by default on all forms
- **HTTPS Enforcement**: Automatic redirect to HTTPS in production
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS
- **Open Redirect Prevention**: URL validation on login redirect
- **Authorization Checks**: Proper ownership validation on all sensitive operations
## Discord Integration for One on One Requests Ce sont les **deux seuls** points d'entrée.
The application supports sending Discord direct messages to coaches when players request One on One sessions. ## Vérifier
### Setup Instructions ```bash
.venv/Scripts/python -m pytest # suite complète
#### 1. Create a Discord Bot .venv/Scripts/python -m ruff check . # lint
.venv/Scripts/python -m ruff format --check .
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Create a new application
3. Go to the "Bot" tab and create a bot user
4. Copy the bot token - this will be your `DISCORD_BOT_TOKEN`
5. Enable the "Message Content Intent" under Privileged Gateway Intents (required for sending messages)
#### 2. Configure Environment Variables
Add the following to your `.env` file (create one if it doesn't exist):
```
DISCORD_BOT_TOKEN=your_bot_token_here
DISCORD_WEBHOOK_URL=optional_webhook_url_for_backup
``` ```
- `DISCORD_BOT_TOKEN`: Required for sending direct messages to coaches Les trois tournent en CI et y sont bloquants.
- `DISCORD_WEBHOOK_URL`: Optional fallback for webhook-based notifications
#### 3. Add Coaches to the Bot ---
For the bot to send DMs to coaches: ## Ce que fait l'application
1. Each coach must have the bot added to their Discord server OR be friends with the bot
2. Coaches need to add their Discord User ID to their profile:
- Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode)
- Right-click on their profile → Copy ID
- Enter this numeric ID in the "Discord User ID" field in their profile settings
### How It Works - **Comptes et rôles** — cinq rôles : président (`admin`), gérant
(`manager`), coach, joueur (`player`), recruteur (`scout`). Le président
attribue les rôles.
- **Sélections** — organisation des tryouts, trois formats de match
(équipe contre équipe, joueur contre joueur, scrim), évaluation des
joueurs sur dix critères.
- **Équipes** — effectifs de la saison, matchs et entraînements. Le
formulaire d'entraînement affiche les disponibilités des joueurs.
- **Disponibilités** — créneaux hebdomadaires des joueurs, créneaux
réservables des coachs.
- **Notes** — un coach écrit des notes d'équipe (visibles par l'équipe) et
des notes nominatives (visibles par le joueur concerné).
- **Un-à-un** — un joueur demande une séance à son coach ; le coach répond
depuis le site ou par une réaction sur le message privé Discord.
- **Contrats** — dépôt d'un contrat par le staff, signature par le joueur.
When a player submits a One on One request: ## Comment c'est construit
1. The system checks if the coach has a Discord User ID configured
2. If configured, a direct message is sent to the coach via the Discord bot
3. If the bot fails or no Discord User ID is set, the system falls back to the webhook URL (if configured)
4. The message includes player name, team, requested date/time, and discussion points
### Message Format Backend Python 3.12 / Flask, rendu serveur en Jinja2, CSS et JavaScript
maison, sans framework front. Base PostgreSQL via SQLAlchemy. Bot Discord
(`discord.py`) dans un fil du même processus que le serveur web.
The Discord DM includes: `docs/architecture.md` contient les diagrammes (classes, paquets, flux
- Player name d'une requête).
- Team name
- Requested date and time slot ---
- Discussion points (if provided)
- Link to the application for approval/rejection ## Sécurité
En place et vérifié par des tests :
- **Limitation de débit** sur la connexion (10 requêtes/minute par IP).
- **Cookies de session** `HttpOnly`, `SameSite=Lax`, `Secure`, avec
expiration effective.
- **CSRF** sur tous les formulaires, y compris la déconnexion (en POST).
- **HTTPS** forcé en production, **HSTS**.
- **CSP sans `unsafe-inline`** sur `script-src` : aucun gestionnaire
d'événement en ligne, chaque bloc `<script>` porte un nonce par requête.
- **Redirections** validées (rien ne sort du site).
- **Validation** par schémas marshmallow sur les formulaires de compte, avec
politique de mot de passe.
- **Autorisation** centralisée dans `app/permissions.py`.
- **Journal d'authentification** (`logs/auth.log`) : connexions, échecs,
changements de rôle, suppressions de compte.
- **Téléversements** vérifiés par extension *et* par signature de fichier.
Ce qui **n'est pas** fait, pour que personne ne s'y fie :
- **Aucune migration de schéma.** `db.create_all()` crée les tables
manquantes et ne modifie jamais une table existante : une colonne ajoutée
à un modèle est absente de la production.
- **Les secrets de l'historique git ne sont pas révoqués** — jeton du bot,
mot de passe PostgreSQL, `SECRET_KEY`.
- **`trusted_proxy='*'`** reste le défaut : l'en-tête `X-Forwarded-For` est
accepté de n'importe quelle source, donc la limitation par IP est
contournable. C'est désormais la variable `TRUSTED_PROXY` plutôt qu'une
constante — `docs/deployment.md` donne la valeur pour chaque topologie.
- **Les comptes créés par le formulaire d'inscription sont actifs
immédiatement** : il n'y a pas d'étape de validation par le staff. Décision
de produit en attente, voir `docs/roles-and-permissions.md`.
- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité
OAuth reste désormais côté serveur, le profil ne peut plus réécrire le
snowflake et l'application refuse les nouvelles collisions. Les doublons
historiques doivent être relevés puis corrigés avant la contrainte
`UNIQUE` (`schema_report.py --check-discord-identities`).
`docs/security-checklist.md` détaille la liste avant mise en production.
### Documentation
| Document | Pour |
|---|---|
| `docs/deployment.md` | Installer, déployer, revenir en arrière |
| `docs/roles-and-permissions.md` | Qui peut faire quoi, et où c'est décidé |
| `docs/database-restore.md` | Sauvegarder et restaurer |
| `docs/database-schema.md` | Sortir de `create_all()` : relevé, Alembic, migrations |
| `docs/incident-runbook.md` | Quand quelque chose ne va pas |
| `docs/architecture.md` | Diagrammes |
| `docs/translations.md` | Ajouter ou corriger une traduction |
| `docs/security-checklist.md` | Avant une mise en production |
---
## Intégration Discord
Messages privés au coach lors d'une demande d'un-à-un, aux joueurs à la
création d'un match ou d'un entraînement les concernant, et rappel 24 h
avant un match. Les réponses se font par réaction sur le message ou depuis
le site.
L'état des messages en attente de réponse est dans `discord_pending.json`,
**non versionné** : c'est de l'état d'exécution, propre à chaque serveur.
### Mise en place
Le bot du club existe déjà ; ce qui suit ne concerne qu'une nouvelle
installation.
1. Créer une application sur le [portail développeur
Discord](https://discord.com/developers/applications), puis un bot.
2. Copier le jeton dans `DISCORD_BOT_TOKEN`.
3. Activer **Message Content Intent** dans les *Privileged Gateway Intents*.
C'est le seul intent privilégié demandé : il sert à lire le motif d'un
refus écrit en réponse au message.
4. Chaque personne doit partager un serveur avec le bot (ou l'avoir en ami)
pour recevoir un message privé, et renseigner son identifiant Discord
dans son profil (Discord → Paramètres → Avancés → Mode développeur, puis
clic droit sur son profil → Copier l'identifiant).
`/health` indique si le bot tourne et s'il est connecté.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-379
View File
@@ -1,379 +0,0 @@
"""Team Tryouts Application - Flask Application Factory.
This module provides the application factory for creating and configuring
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 extensions import db, login_manager, csrf, hash_password, check_password, limiter
from sqlalchemy import text
from werkzeug.exceptions import HTTPException
import markupsafe
from dotenv import load_dotenv
load_dotenv()
def nl2br(value):
"""Convert newlines to HTML line breaks.
Args:
value: String value to convert.
Returns:
Markup: HTML-safe string with line breaks.
"""
if value:
return markupsafe.Markup('<br>'.join(str(value).splitlines()))
return ''
def create_app():
"""Create and configure the Flask application.
Initializes Flask with:
- Secret key for session security
- Database configuration
- CSRF protection
- CORS with restricted origins
- Login manager
- Rate limiting
- All route blueprints
- Security headers and HTTPS redirects
- Custom error handlers
- Health check endpoint
- Structured logging
Handles database initialization and seeding with sample data if empty.
Returns:
Flask: Configured Flask application instance.
"""
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
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', 'sqlite:///team_tryouts.db')
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
# Secure session cookie settings
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
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 = [origin.strip() for origin in allowed_origins if origin.strip()]
if allowed_origins:
CORS(
app,
origins=allowed_origins,
supports_credentials=True,
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
max_age=3600, # Cache preflight for 1 hour
)
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=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
max_age=3600,
)
db.init_app(app)
login_manager.init_app(app)
csrf.init_app(app)
limiter.init_app(app)
# Configure structured logging
from logging_config import configure_logging
configure_logging(app)
from routes.auth import auth_bp
from routes.tryouts import tryouts_bp
from routes.evaluations import evaluations_bp
from routes.users import users_bp
from routes.main import main_bp
from routes.teams import teams_bp
from routes.matches import matches_bp
app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp)
app.register_blueprint(evaluations_bp)
app.register_blueprint(users_bp)
app.register_blueprint(main_bp)
app.register_blueprint(teams_bp)
app.register_blueprint(matches_bp)
# Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br
# =========================================================================
# Security Headers
# =========================================================================
@app.after_request
def add_security_headers(response):
"""Add security headers to all responses.
Implements defense-in-depth with comprehensive HTTP security headers.
These complement the headers set by Nginx in production.
HSTS is only sent in production (non-debug) to avoid breaking
local development over plain HTTP.
"""
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=(), '
'interest-cohort=(), payment=(), usb=()'
)
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
"font-src 'self' https://cdnjs.cloudflare.com; "
"img-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
# Only enable HSTS when HTTPS is actually being used
# (either direct TLS or behind a proxy that terminates TLS)
is_https = request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
if is_https:
response.headers['Strict-Transport-Security'] = (
'max-age=31536000; includeSubDomains; preload'
)
return response
# =========================================================================
# HTTPS Redirect (Production only)
# =========================================================================
@app.before_request
def force_https():
"""Redirect all HTTP requests to HTTPS in production.
Respects the X-Forwarded-Proto header from reverse proxies.
Can be disabled via FORCE_HTTPS environment variable.
"""
if not app.debug:
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)
# =========================================================================
# Health Check Endpoint
# =========================================================================
@app.route('/health')
def health_check():
"""Health check endpoint for monitoring and load balancers.
Verifies database connectivity and application health.
Returns 200 with basic status info or 503 if unhealthy.
Returns:
Response: JSON health status.
"""
health_data = {
'status': 'healthy',
'app': 'team-tryouts',
'version': '1.0.0',
}
# Check database connectivity
try:
db.session.execute(text('SELECT 1'))
health_data['database'] = 'connected'
except Exception as e:
health_data['status'] = 'unhealthy'
health_data['database'] = f'error: {str(e)}'
return jsonify(health_data), 503
return jsonify(health_data), 200
# =========================================================================
# Custom Error Handlers
# =========================================================================
@app.errorhandler(400)
def bad_request(error):
"""Handle 400 Bad Request errors.
Args:
error: The error object.
Returns:
Response: Rendered error page or JSON for API requests.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/coach-availability') or \
request.path.startswith('/users/api/'):
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
return render_template('errors/400.html', error=error), 400
@app.errorhandler(401)
def unauthorized(error):
"""Handle 401 Unauthorized errors.
Args:
error: The error object.
Returns:
Response: Redirect to login for pages, JSON for API.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
return jsonify({'error': 'Unauthorized'}), 401
from flask import flash as _flash
_flash('Please log in to access this page.', 'warning')
return redirect(url_for('auth.login'))
@app.errorhandler(403)
def forbidden(error):
"""Handle 403 Forbidden errors.
Args:
error: The error object.
Returns:
Response: Rendered error page or JSON for API requests.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
return render_template('errors/403.html', error=error), 403
@app.errorhandler(404)
def not_found(error):
"""Handle 404 Not Found errors.
Args:
error: The error object.
Returns:
Response: Rendered error page or JSON for API requests.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
return jsonify({'error': 'Not found'}), 404
return render_template('errors/404.html', error=error), 404
@app.errorhandler(429)
def too_many_requests(error):
"""Handle 429 Too Many Requests errors.
Args:
error: The error object.
Returns:
Response: JSON error for API or rendered page.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
return jsonify({
'error': 'Too many requests',
'message': 'Please try again later.'
}), 429
return render_template('errors/429.html', error=error), 429
@app.errorhandler(500)
def internal_error(error):
"""Handle 500 Internal Server Error.
Never exposes stack traces to users. Logs the full error internally.
Args:
error: The error object.
Returns:
Response: Generic error page or JSON.
"""
# Log the full error for debugging
app.logger.error('Internal Server Error: %s', str(error), exc_info=True)
# Roll back any failed database session
db.session.rollback()
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred. Please try again later.'
}), 500
return render_template('errors/500.html'), 500
@app.errorhandler(HTTPException)
def handle_http_exception(error):
"""Catch-all handler for any unhandled HTTP exceptions.
Args:
error: The HTTPException object.
Returns:
Response: JSON error for API, re-raises for others.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
return jsonify({
'error': error.name,
'message': error.description,
'code': error.code
}), error.code
return error
# =========================================================================
# Database Initialization
# =========================================================================
with app.app_context():
import models
from models import User
try:
# Check if the database schema is up to date by testing a query
db.session.execute(text('SELECT games, team_side FROM match_participants LIMIT 1'))
db.create_all()
except Exception:
# If there's a schema mismatch, drop and recreate all tables
db.session.rollback()
db.drop_all()
db.create_all()
# Seed database if empty
if User.query.count() == 0:
from seed import seed_database
seed_database()
# Start the Discord bot for notifications
try:
from discord_bot import start_bot
start_bot()
except Exception as e:
app.logger.warning('Could not start Discord bot: %s', e)
return app
if __name__ == '__main__':
# Only used for development - production uses wsgi.py (Waitress)
app = create_app()
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
if debug_mode:
app.logger.warning(
'Running in DEBUG mode with Flask built-in server. '
'This is NOT suitable for production. Use wsgi.py instead.'
)
app.run(debug=debug_mode, host='127.0.0.1', port=5000)
+144
View File
@@ -0,0 +1,144 @@
# Team Tryouts — environment variables
#
# Copy to .env and fill in. Every value here is a PRODUCTION-SAFE default:
# copying this file and changing nothing gives a locked-down configuration
# that refuses to start until the two required secrets are set, rather than
# a working one that happens to be wide open (OPS-003).
#
# The previous version shipped FLASK_DEBUG=true under a heading that said
# "fill in the values for production". The Werkzeug debugger executes code
# submitted through the browser, so that one line turned a copy-paste into a
# remote shell.
#
# For local development, see the DEVELOPMENT block at the bottom.
# =============================================================================
# Required — the application refuses to start without these
# =============================================================================
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
# Never reuse one between environments: this key signs session cookies, so
# whoever holds it can forge a session for any account.
SECRET_KEY=
# Expected form: postgresql://user:password@host:5432/database
# The psycopg 3 driver is named for you by create_app(); postgresql:// alone
# would send SQLAlchemy looking for psycopg2, which is not installed.
DATABASE_URL=
# =============================================================================
# Security — these defaults assume HTTPS in front. Do not relax them on a
# deployed instance.
# =============================================================================
# Session cookies are only sent over HTTPS.
SESSION_COOKIE_SECURE=true
# Plain HTTP is redirected to HTTPS.
FORCE_HTTPS=true
# The Werkzeug debugger is a remote code execution primitive by design.
# Never true on anything reachable from a network you do not control.
FLASK_DEBUG=false
# Inline <script> without a nonce. Off: every block carries one, and turning
# this on gives up the protection that would have blocked the stored XSS
# (SEC-WEB-001). It exists as an escape hatch, not as a setting to tune.
CSP_ALLOW_INLINE_SCRIPT=false
# Comma-separated origins allowed to call this API cross-site. Empty means
# no CORS policy at all, which is correct: the site renders its own HTML on
# one origin and needs none.
CORS_ALLOWED_ORIGINS=
# =============================================================================
# Networking
# =============================================================================
# Interface Waitress binds. 127.0.0.1 keeps it reachable only through the
# local reverse proxy; 0.0.0.0 exposes it directly and is only correct if
# something else in front is doing the filtering.
HOST=127.0.0.1
PORT=5000
# Whether to believe X-Forwarded-For, and from whom. This decides which IP
# the rate limiter and the audit log record.
#
# (empty) — trust nobody. Correct when nothing proxies the app.
# 127.0.0.1 — trust a reverse proxy on this same machine. The usual case.
# * — trust everyone. Only ever correct if the app cannot be reached
# except through the proxy, at the network level. Otherwise any
# caller can claim any IP and walk around the rate limit.
#
# See docs/deployment.md before changing this (OPS-002).
TRUSTED_PROXY=127.0.0.1
# =============================================================================
# Optional — Discord
# =============================================================================
# Leave ENABLE_DISCORD_BOT=false and the token empty to run without Discord.
ENABLE_DISCORD_BOT=false
DISCORD_BOT_TOKEN=
# OAuth2, for "Connect Discord" on the sign-up page.
# Create an application at https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
# =============================================================================
# Optional — storage
# =============================================================================
# Where uploaded contracts live. Empty means `documents/` beside the
# application. Set it to a path OUTSIDE the deployment directory if you move
# to a release-directory layout, or a deployment will take the documents with
# it (OPS-011, app/storage.py).
#
# IMPORTANT: the backup script reads this same variable. Before wave J it
# did not, and archived `./documents` regardless — so setting this here and
# nowhere else produced empty contract backups that still exited 0.
DOCUMENTS_ROOT=
# Where the log files go. Empty means `logs/` beside the application. Both
# defaults are anchored on the application, not on the directory the process
# was started from, which is what they used to be (OBS-006).
LOG_DIR=
# Where the backup script writes its archives. Empty means `backups/` beside
# the application.
BACKUP_DIR=
# =============================================================================
# Optional — rate limiting
# =============================================================================
# Where the rate limiter keeps its counters. Empty means `memory://`, which
# is correct for a single Waitress process and is what this deployment runs.
#
# Set it to a shared backend (redis://…) BEFORE running more than one worker:
# in-memory counters are per-process, so N workers let through N times every
# configured limit, with nothing to show for it in the logs.
#
# Note that shared storage does not by itself make the limits sound: they are
# keyed on the client IP, which is forgeable until TRUSTED_PROXY is set
# correctly (SEC-WEB-002 / OPS-002 — see above).
RATELIMIT_STORAGE_URI=
# Tables are created at startup when missing. Set to false once Alembic owns
# the schema (DB-002/DB-004): create_all() never ALTERs, so a column added to
# a model is silently absent from an existing database.
AUTO_CREATE_TABLES=true
# =============================================================================
# DEVELOPMENT ONLY — the values to change on a laptop, and nowhere else
# =============================================================================
#
# FLASK_DEBUG=true reloader and interactive debugger
# SESSION_COOKIE_SECURE=false cookies over plain HTTP
# FORCE_HTTPS=false no redirect to HTTPS
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
#
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
# development session cannot accidentally inherit a production binding.
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+47
View File
@@ -0,0 +1,47 @@
"""Marking the views whose answers — including their failures — are JSON.
STD-09. Deciding "JSON or HTML page" from the URL path could not work here,
and the audit's own recommendation ("gestion d'erreurs API par préfixe d'URL
codé en dur", fix the prefixes) would not have fixed it either. Three of the
sixteen JSON views sit at paths no prefix can single out:
/matches/<int:match_id>/toggle-presence/<int:participant_id>
/team-matches/<int:match_id>/toggle-presence/<int:participant_id>
/teams/<int:team_id>/toggle_status/<int:player_id>
They are interleaved with the HTML routes of the same blueprints, and the
templates fetch them. Any prefix wide enough to catch them catches every
page of the section with them.
So the view says so itself. `wants_json_response()` in app.py reads the mark
off the registered view function, and `tests/test_api_error_format.py` walks
the URL map to prove that every view calling `jsonify` carries it — the
mechanism that was missing before was not a better list, it was anything at
all that checked the list.
Usage — directly under the route decorator, above `login_required`, so the
mark lands on the object the route registers::
@matches_bp.route('/api/events')
@json_endpoint
@login_required
def api_events():
...
"""
def json_endpoint(view):
"""Mark a view as answering in JSON, errors included.
Args:
view: The view function, already wrapped by any decorator below this
one (`login_required` in every current case).
Returns:
The same object, with the mark set. Nothing is wrapped: an extra
wrapper here would be one more thing between Flask and the view for
no gain, and `functools.wraps` copying `__dict__` is exactly the
detail that would make this fragile.
"""
view.returns_json = True
return view
+726
View File
@@ -0,0 +1,726 @@
"""Team Tryouts Application - Flask Application Factory.
This module provides the application factory for creating and configuring
the Flask application instance with comprehensive security hardening.
"""
import os
import secrets
import markupsafe
from dotenv import load_dotenv
from flask import (
Flask,
current_app,
flash,
g,
jsonify,
redirect,
render_template,
request,
url_for,
)
from flask_babel import gettext as _
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
from app.pagination import page_url
load_dotenv()
#: Fallback for requests that never reached a view: a 404 on an unrouted
#: path has no endpoint to read a mark off, and `/users/api/typo` should
#: still answer a fetch() in JSON.
#:
#: Not the primary mechanism. Seven error handlers each carried their own
#: copy of a list like this (STD-09); the copies had drifted, and all seven
#: were missing the same endpoints. Views now mark themselves — see
#: `app/api.py` for why a prefix list could not have been made correct.
JSON_URL_PREFIXES = (
'/users/disponibilities',
'/users/coach-availability',
'/users/api/',
'/matches/api/',
'/team-matches/api/',
)
def wants_json_response():
"""Whether this request must be answered with JSON rather than an HTML page.
Three signals, in order of authority: the view said so (`@json_endpoint`),
the path is under a JSON prefix (for requests that matched no view at
all), or the caller asked for JSON and nothing else.
"""
view = current_app.view_functions.get(request.endpoint) if request.endpoint else None
if getattr(view, 'returns_json', False):
return True
if request.path.startswith(JSON_URL_PREFIXES):
return True
accept = request.accept_mimetypes
return accept.best == 'application/json' and not accept.accept_html
def nl2br(value):
"""Convert newlines to HTML line breaks.
Args:
value: String value to convert.
Returns:
Markup: HTML-safe string with line breaks.
"""
if value:
# Markup('<br>').join() escapes each segment before joining.
# Markup('<br>'.join(...)) would mark attacker-controlled text as safe.
return markupsafe.Markup('<br>').join(str(value).splitlines())
return ''
def normalise_database_url(url):
"""Name the PostgreSQL driver explicitly in a connection URL.
`postgresql://…` does not mean "whichever driver is installed": it means
psycopg2, which SQLAlchemy imports at create_engine() time. requirements
.txt pins psycopg 3 (`psycopg[binary]`) and no psycopg2, so a clean
install starting against the URL Render hands out — and the one this
project's own documentation shows — raises
ModuleNotFoundError: No module named 'psycopg2'
before the first request. Anything with a driver already spelled out
(`postgresql+psycopg://`, `postgresql+psycopg2://`) is left alone, so
naming psycopg2 stays possible for an environment that has it.
`postgres://` is the legacy alias several hosts still emit; SQLAlchemy
dropped it in 1.4.
Args:
url: Value of DATABASE_URL, or None.
Returns:
str | None: The URL, with a driver named when it was PostgreSQL.
"""
if not url:
return url
scheme, separator, rest = url.partition('://')
if not separator or '+' in scheme:
return url
if scheme in ('postgres', 'postgresql'):
return f'postgresql+psycopg://{rest}'
return url
def build_csp(*, allow_inline_script, nonce=None):
"""Assemble the Content-Security-Policy header.
Two mutually exclusive modes, and they really are exclusive.
Under CSP level 3, a browser that understands nonces **ignores
'unsafe-inline' entirely as soon as a nonce is present**. Emitting both
would therefore not be a gentle transition: it would drop every inline
script and every onclick attribute at once, in modern browsers only.
The switch has to be atomic, which is why one flag drives it.
While allow_inline_script is true no nonce is emitted at all, so adding
nonce="{{ csp_nonce }}" to a template ahead of the switch is harmless.
Flipping the flag requires every inline event handler to be gone first.
A nonce cannot authorise an onclick attribute — nonces apply to script
elements, never to handler attributes. See tests/test_csp.py, which
tracks how many are left.
Args:
allow_inline_script: Keep 'unsafe-inline' in script-src.
nonce: Per-request nonce, used only when inline script is not allowed.
Returns:
str: The header value.
"""
if allow_inline_script:
script_src = "'self' 'unsafe-inline' https://cdn.jsdelivr.net"
else:
script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net"
return '; '.join(
[
"default-src 'self'",
f'script-src {script_src}',
# style-src is a separate migration: inline style="" attributes are
# spread across the templates and are not an XSS vector on their own.
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
"font-src 'self' https://cdnjs.cloudflare.com",
"img-src 'self' data: https://cdn.discordapp.com",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
]
)
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
- CSRF protection
- CORS with restricted origins
- Login manager
- Rate limiting
- All route blueprints
- Security headers and HTTPS redirects
- Custom error handlers
- Health check endpoint
- Structured logging
Handles database initialization and seeding with sample data if empty.
Returns:
Flask: Configured Flask application instance.
"""
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')
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'
# Now false: every inline event handler has been replaced by a
# data-action attribute dispatched from main.js, so script-src no longer
# needs 'unsafe-inline'. Inline <script> blocks carry a per-request
# nonce. The escape hatch remains for a deployment that hits an
# overlooked handler — but leaving it on gives up the protection that
# would have blocked SEC-XSS-001.
app.config['CSP_ALLOW_INLINE_SCRIPT'] = (
os.getenv('CSP_ALLOW_INLINE_SCRIPT', 'false').lower() == 'true'
)
# Internationalisation. French is the site's primary language.
app.config['BABEL_DEFAULT_LOCALE'] = i18n.DEFAULT_LOCALE
app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.path.join(
os.path.dirname(os.path.abspath(__file__)), 'translations'
)
# 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'
# Where the rate limiter keeps its counters (SEC-WEB-004).
#
# `memory://` is what Flask-Limiter falls back to when nothing is set,
# and it is the correct choice here: Waitress serves this application
# from one process, so one set of counters in that process is all there
# is to share. Naming it changes nothing at runtime and two things
# otherwise — it stops being an accident, and it becomes settable to a
# Redis URI on the day the deployment gains a second process, which is
# the day in-memory counters would start letting through N times the
# configured limit without anyone noticing.
#
# What this does not fix: the counters are keyed on an IP address that
# is forgeable while TRUSTED_PROXY is unresolved (OPS-002). Shared
# storage for a forgeable key buys nothing, which is why that one is
# the prerequisite and not this.
app.config['RATELIMIT_STORAGE_URI'] = os.getenv('RATELIMIT_STORAGE_URI', 'memory://')
# --- 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')
if not app.config['SQLALCHEMY_DATABASE_URI']:
raise RuntimeError(
'DATABASE_URL environment variable must be set to a PostgreSQL connection string'
)
# After the overrides, so a caller-supplied URL is normalised too.
app.config['SQLALCHEMY_DATABASE_URI'] = normalise_database_url(
app.config['SQLALCHEMY_DATABASE_URI']
)
# File upload size limit (16 MB)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
# Secure session cookie settings
app.config['SESSION_COOKIE_SECURE'] = (
os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
)
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
# Configure CORS - restrict to specific origins in production
allowed_origins = str(app.config.get('CORS_ALLOWED_ORIGINS') or '').split(',')
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()]
# CORS is only configured when origins are named explicitly.
#
# The previous else-branch called CORS(app, supports_credentials=True)
# with no origins argument. flask-cors then defaults to '*' and, because
# credentials are allowed, echoes back whatever Origin the caller sent
# together with Access-Control-Allow-Credentials: true — the opposite of
# the "allow all (development) or none (production)" the comment claimed.
#
# Exploitation was blocked by SESSION_COOKIE_SAMESITE = 'Lax', which stops
# the browser attaching the session cookie to a cross-site fetch. That is
# a single setting standing between a misconfiguration and a cross-origin
# data leak. This application renders server-side HTML on one origin and
# needs no CORS policy at all.
if allowed_origins:
CORS(
app,
origins=allowed_origins,
supports_credentials=True,
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
max_age=3600, # Cache preflight for 1 hour
)
db.init_app(app)
login_manager.init_app(app)
csrf.init_app(app)
limiter.init_app(app)
# Said once, at startup, because the failure mode is silent: counters in
# process memory are lost on every restart and are not shared, so a
# second worker would double every limit and nothing would report it.
if app.config['RATELIMIT_STORAGE_URI'].startswith('memory://'):
app.logger.info(
'Rate limiting counters are held in process memory. Correct for a '
'single-process deployment; set RATELIMIT_STORAGE_URI to a shared '
'backend before running more than one worker (SEC-WEB-004).'
)
else:
app.logger.info(
'Rate limiting counters are held in a shared backend (%s).',
app.config['RATELIMIT_STORAGE_URI'].split('://', 1)[0],
)
babel.init_app(app, locale_selector=i18n.select_locale)
# Exposed to every template so the language switcher can render itself
# without each view having to pass the list along.
@app.before_request
def generate_csp_nonce():
# Only meaningful once inline script is disallowed; generated
# unconditionally so templates can carry nonce="" beforehand.
g.csp_nonce = secrets.token_urlsafe(16)
@app.before_request
def assign_request_id():
"""Give this request a name, so its log lines can be found (OBS-005).
Every record emitted while handling it carries this id — see
RequestIdFilter — which is what turns "an error happened around
14:32" into the six lines that led to it. It goes back in
X-Request-Id and onto the 500 page, so that a report of "it broke
when I clicked save" is enough to find the trace.
Generated here, never taken from an inbound header: with no trusted
proxy settled (OPS-002), an accepted header lets any caller write
arbitrary text — newlines included — into the log file.
"""
g.request_id = secrets.token_hex(8)
@app.after_request
def expose_request_id(response):
response.headers['X-Request-Id'] = g.get('request_id', '-')
return response
@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 {
'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT'] else g.get('csp_nonce', '')
}
@app.context_processor
def inject_locales():
from flask_babel import get_locale
return {
'current_locale': str(get_locale() or i18n.DEFAULT_LOCALE),
'supported_locales': i18n.SUPPORTED_LOCALES,
'locale_names': i18n.LOCALE_NAMES,
}
# Used by layouts/_pagination.html. A global rather than something each
# listing passes, because the thing that goes wrong with pagination links
# is dropping the rest of the query string — `sort`, `order`, `team_id` —
# and that is easier to get right once than in four templates (MNT-14).
app.jinja_env.globals['page_url'] = page_url
# Configure structured logging
from app.logging_config import configure_logging
configure_logging(app)
from app.routes.auth import auth_bp
from app.routes.evaluations import evaluations_bp
from app.routes.main import main_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)
app.register_blueprint(evaluations_bp)
app.register_blueprint(users_bp)
app.register_blueprint(main_bp)
app.register_blueprint(teams_bp)
app.register_blueprint(matches_bp)
app.register_blueprint(team_matches_bp)
# Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br
# =========================================================================
# Security Headers
# =========================================================================
@app.after_request
def add_security_headers(response):
"""Add security headers to all responses.
Implements defense-in-depth with comprehensive HTTP security headers.
These complement the headers set by Nginx in production.
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['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Permissions-Policy'] = (
'camera=(), microphone=(), geolocation=(), interest-cohort=(), payment=(), usb=()'
)
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
response.headers['Content-Security-Policy'] = build_csp(
allow_inline_script=app.config['CSP_ALLOW_INLINE_SCRIPT'],
nonce=g.get('csp_nonce'),
)
# Only enable HSTS when HTTPS is actually being used
# (either direct TLS or behind a proxy that terminates TLS)
is_https = request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
if is_https:
response.headers['Strict-Transport-Security'] = (
'max-age=31536000; includeSubDomains; preload'
)
return response
# =========================================================================
# HTTPS Redirect (Production only)
# =========================================================================
@app.before_request
def force_https():
"""Redirect all HTTP requests to HTTPS in production.
Respects the X-Forwarded-Proto header from reverse proxies.
Can be disabled via FORCE_HTTPS environment variable.
Returns:
Response | None: A redirect, or None to let the request through.
"""
if not app.debug and app.config['FORCE_HTTPS']:
already_secure = (
request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
)
if not already_secure:
return redirect(request.url.replace('http://', 'https://'), code=301)
return None
# =========================================================================
# Health Check Endpoint
# =========================================================================
@app.route('/health')
def health_check():
"""Health check endpoint for monitoring and load balancers.
Verifies database connectivity and application health.
Returns 200 with basic status info or 503 if unhealthy.
Returns:
Response: JSON health status.
"""
health_data = {
'status': 'healthy',
'app': 'team-tryouts',
'version': '1.0.0',
}
# The bot runs in a daemon thread inside this process. When it dies
# the site keeps serving pages and every notification stops, with
# nothing to see from outside — which is how it stayed unnoticed.
# Reported, not fatal: a club without Discord reminders is degraded,
# not down, and a 503 here would take the site out of the load
# balancer for it (OPS-012).
if app.config['ENABLE_DISCORD_BOT']:
from app.discord_bot import bot_status
health_data['discord_bot'] = bot_status()
# Check database connectivity
try:
db.session.execute(text('SELECT 1'))
health_data['database'] = 'connected'
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'] = 'error'
return jsonify(health_data), 503
return jsonify(health_data), 200
# =========================================================================
# Custom Error Handlers
# =========================================================================
#
# STD-09. The seven handlers below each carried their own copy of a list
# of URL prefixes, and the copies had drifted: three of them checked
# `/users/coach-availability`, four did not. Worse, every copy was
# missing the same five endpoints — `/matches/api/…` and
# `/team-matches/api/…` — so an error on any of those answered a
# `fetch()` with an HTML error page. The browser then failed to parse it
# as JSON and the page simply did nothing: on the calendar, the tryout
# and team selects stayed empty with no message anywhere. A session that
# expired mid-page produced exactly that, because the 401 handler
# redirects to an HTML login form.
#
# The mechanism now lives in wants_json_response() / app/api.py, and a
# test walks the URL map to prove no jsonify-returning view is missed.
#
# The handler below is the one that actually mattered. `@login_required`
# never reaches the 401 handler: Flask-Login intercepts first and calls
# its own unauthorized callback, which redirects. So every one of the
# sixteen JSON endpoints answered an expired session with a 302 to an
# HTML login form, whatever the prefix list said — and the page's
# `fetch()` threw parsing it. Rewriting the prefix list alone would have
# left this untouched and looked like a fix.
@login_manager.unauthorized_handler
def handle_unauthorized():
"""What an unauthenticated request gets: a redirect, or a 401 in JSON."""
if wants_json_response():
return jsonify({'error': 'Unauthorized', 'message': 'Your session has expired.'}), 401
flash(_('Please log in to access this page.'), 'warning')
return redirect(url_for('auth.login'))
@app.errorhandler(400)
def bad_request(error):
"""Handle 400 Bad Request errors.
Args:
error: The error object.
Returns:
Response: Rendered error page or JSON for API requests.
"""
if wants_json_response():
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
return render_template('errors/400.html', error=error), 400
@app.errorhandler(401)
def unauthorized(error):
"""Handle an explicit abort(401).
Rarely reached: `@login_required` is intercepted by Flask-Login
before Flask's error handling, and answered by handle_unauthorized
above. This covers code that aborts with 401 itself, and gives the
same answer — the two used to differ, and the flash message here was
the one string in the application that had never been translated.
"""
return handle_unauthorized()
@app.errorhandler(403)
def forbidden(error):
"""Handle 403 Forbidden errors.
Args:
error: The error object.
Returns:
Response: Rendered error page or JSON for API requests.
"""
if wants_json_response():
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
return render_template('errors/403.html', error=error), 403
@app.errorhandler(404)
def not_found(error):
"""Handle 404 Not Found errors.
Args:
error: The error object.
Returns:
Response: Rendered error page or JSON for API requests.
"""
if wants_json_response():
return jsonify({'error': 'Not found'}), 404
return render_template('errors/404.html', error=error), 404
@app.errorhandler(429)
def too_many_requests(error):
"""Handle 429 Too Many Requests errors.
Args:
error: The error object.
Returns:
Response: JSON error for API or rendered page.
"""
if wants_json_response():
return jsonify(
{'error': 'Too many requests', 'message': 'Please try again later.'}
), 429
return render_template('errors/429.html', error=error), 429
@app.errorhandler(500)
def internal_error(error):
"""Handle 500 Internal Server Error.
Never exposes stack traces to users. Logs the full error internally.
Args:
error: The error object.
Returns:
Response: Generic error page or JSON.
"""
# Log the full error for debugging
app.logger.error('Internal Server Error: %s', str(error), exc_info=True)
# Roll back any failed database session
db.session.rollback()
# The id is the only thing that connects a user saying "it broke when
# I clicked save" to the stack trace in errors.log. It identifies one
# request and nothing else — no session, no account, nothing an
# attacker can use — so showing it costs nothing (OBS-005).
request_id = g.get('request_id', '-')
if wants_json_response():
return jsonify(
{
'error': 'Internal server error',
'message': 'An unexpected error occurred. Please try again later.',
'request_id': request_id,
}
), 500
return render_template('errors/500.html', request_id=request_id), 500
@app.errorhandler(HTTPException)
def handle_http_exception(error):
"""Catch-all handler for any unhandled HTTP exceptions.
Args:
error: The HTTPException object.
Returns:
Response: JSON error for API, re-raises for others.
"""
if wants_json_response():
return jsonify(
{'error': error.name, 'message': error.description, 'code': error.code}
), error.code
return error
# =========================================================================
# Database Initialization
# =========================================================================
with app.app_context():
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
# 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
if app.config['ENABLE_DISCORD_BOT']:
try:
from app.discord_bot import start_bot
start_bot(flask_app=app)
except Exception: # the site must come up even if the bot cannot
# With the message alone, the two ways this fails — a bad token
# and a broken import in discord_bot — read identically, and
# neither is diagnosable from one line. Notifications are down
# either way, so the traceback is the whole value of the log.
# Error, not warning: a club that receives no reminders has lost
# a feature, and the old level put that next to the deprecation
# notices.
app.logger.error(
'Could not start the Discord bot. The site is up; no notification '
'will be sent until this is fixed.',
exc_info=True,
)
return app
# No __main__ block here on purpose. There used to be one, and with run.py
# and wsgi.py that made three ways to start the application, each with its
# own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
# while `python run.py` bound 127.0.0.2:5000 with the debugger on. This
# module defines the factory; run.py starts it for development, wsgi.py for
# production (ARCH-007).
+1581
View File
File diff suppressed because it is too large Load Diff
+15 -9
View File
@@ -1,9 +1,10 @@
from flask_sqlalchemy import SQLAlchemy from flask_babel import Babel
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from werkzeug.security import generate_password_hash, check_password_hash
from flask_limiter import Limiter from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
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 # Database and extension initialization
db = SQLAlchemy() db = SQLAlchemy()
@@ -12,11 +13,16 @@ login_manager.login_view = 'auth.login'
login_manager.login_message_category = 'info' login_manager.login_message_category = 'info'
csrf = CSRFProtect() csrf = CSRFProtect()
# Rate limiter for brute-force protection # Internationalisation. French is the primary language of the site; English
limiter = Limiter( # stays available. See app/i18n.py for how a locale is chosen.
key_func=get_remote_address, babel = Babel()
default_limits=["200 per day", "50 per hour"]
) # Rate limiter for brute-force protection.
#
# No storage is named here on purpose: it comes from RATELIMIT_STORAGE_URI in
# app.config, which create_app fills from the environment and defaults to
# `memory://` (SEC-WEB-004). Naming it in both places is how the two drift.
limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "50 per hour"])
def hash_password(password): def hash_password(password):
+63
View File
@@ -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
+75
View File
@@ -0,0 +1,75 @@
"""Language selection.
French is the primary language of the site; English remains available.
Source strings stay in English and act as gettext message ids, with the
French wording supplied by translations/fr/LC_MESSAGES/messages.po. That
keeps the codebase in one language — the same one as its comments and
docstrings — while what a member actually sees defaults to French.
Consequence worth knowing: an English page is what you get when a string
has no French translation yet. A missing entry degrades to English rather
than to a raw identifier, which is why the migration can proceed template
by template without ever leaving the site in a broken state.
"""
from flask import request, session
#: Locales the site is served in, in order of preference.
SUPPORTED_LOCALES = ('fr', 'en')
#: Language names as written in their own language, for the switcher.
LOCALE_NAMES = {
'fr': 'Français',
'en': 'English',
}
#: Session key holding an explicit user choice.
LOCALE_SESSION_KEY = 'locale'
DEFAULT_LOCALE = 'fr'
def select_locale():
"""Pick the locale for the current request.
Order of precedence:
1. an explicit choice the user made through the language switcher,
kept in the session;
2. the browser's Accept-Language header, restricted to what we serve;
3. French.
Note that step 2 only ever selects English for someone whose browser
actually asks for it. Everyone else gets French, including browsers
sending no header at all.
Returns:
str: A locale code from SUPPORTED_LOCALES.
"""
chosen = session.get(LOCALE_SESSION_KEY)
if chosen in SUPPORTED_LOCALES:
return chosen
# best_match returns None when nothing overlaps.
if request:
negotiated = request.accept_languages.best_match(SUPPORTED_LOCALES)
if negotiated:
return negotiated
return DEFAULT_LOCALE
def set_locale(locale):
"""Record an explicit language choice for this session.
Args:
locale: Requested locale code.
Returns:
bool: True if it was accepted, False if unsupported.
"""
if locale not in SUPPORTED_LOCALES:
return False
session[LOCALE_SESSION_KEY] = locale
return True
+273
View File
@@ -0,0 +1,273 @@
"""Structured logging configuration for the Team Tryouts application.
This module configures rotating file handlers for application logs,
with separate files for errors, authentication events, and general logs.
Sensitive data (passwords, tokens) is automatically filtered out.
Usage:
from logging_config import configure_logging
configure_logging(app)
"""
import logging
import os
import re
from logging.handlers import RotatingFileHandler
from app.storage import logs_root
#: Value used when a record is emitted outside a request — startup, the
#: Discord bot thread, the scheduler. Short and obviously not an id, so a
#: grep for one never matches it by accident.
NO_REQUEST = '-'
class RequestIdFilter(logging.Filter):
"""Stamp every record with the id of the request that produced it.
Without this, a 500 in errors.log and the six lines in app.log that led
to it are related only by their timestamps, which is not a relation when
the server is handling more than one request at a time (OBS-005).
The id is generated per request and never read from an inbound header.
Accepting one would be convenient for tracing across nginx, and it would
also let any caller write arbitrary text — newlines included — into the
log file, which is how a log gets forged rather than read. There is no
trusted proxy to take it from while OPS-002 is open.
"""
def filter(self, record):
record.request_id = NO_REQUEST
try:
from flask import g, has_request_context
if has_request_context():
record.request_id = g.get('request_id', NO_REQUEST)
except Exception: # noqa: BLE001 — logging must never be the thing that fails
pass
return True
class SensitiveDataFilter(logging.Filter):
"""Logging filter that redacts sensitive information from log messages.
Filters out: passwords, API keys, session tokens, and other secrets
that might accidentally be logged.
"""
# Patterns to redact
SENSITIVE_PATTERNS = [
(
re.compile(
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE
),
'[REDACTED]',
),
(
re.compile(
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE
),
lambda m: m.group(0).split('=')[0] + '="[REDACTED]"',
),
(re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
]
def filter(self, record):
"""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).
"""
try:
rendered = record.getMessage()
except Exception: # noqa: BLE001 — see below; this one cannot log its own failure
# A malformed format string must not lose the record entirely.
# Nor can it be logged: this runs inside a filter, and logging
# from here re-enters the same filter on the new record. The
# traceback BLE001 normally asks for is the one thing this
# handler must not produce, hence the waiver.
return True
for pattern, replacement in self.SENSITIVE_PATTERNS:
rendered = pattern.sub(replacement, rendered)
record.msg = rendered
record.args = ()
return True
def configure_logging(app):
"""Configure structured logging for the Flask application.
Sets up three rotating file handlers:
- errors.log: ERROR and CRITICAL level messages
- auth.log: Authentication-related events (INFO and above)
- app.log: All application logs (DEBUG and above, configurable)
Also configures console output for development.
Args:
app: The Flask application instance to configure logging for.
"""
# Anchored on the project, not on the working directory (OBS-006). The
# old form put the logs wherever the process happened to be started
# from, so a service restarted by hand from another directory quietly
# began writing somewhere else — and the file you go looking at when
# something is wrong is the one that must not move.
log_dir = logs_root()
os.makedirs(log_dir, exist_ok=True)
# Remove default Flask handlers to avoid duplicate logging
app.logger.handlers.clear()
# Set base log level from environment (default: INFO)
log_level_name = os.getenv('LOG_LEVEL', 'INFO').upper()
log_level = getattr(logging, log_level_name, logging.INFO)
app.logger.setLevel(log_level)
# Create the sensitive data filter
sensitive_filter = SensitiveDataFilter()
request_id_filter = RequestIdFilter()
# Formatter with timestamp, level, module, request id, and message.
#
# request_id comes from RequestIdFilter, which is attached to every
# handler below. A handler that formats with this string and does not
# carry the filter raises on its first record — so if one is ever added,
# add the filter with it.
formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] [%(request_id)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)
# -------------------------------------------------------------------------
# 1. Error Log Handler
# -------------------------------------------------------------------------
error_handler = RotatingFileHandler(
os.path.join(log_dir, 'errors.log'),
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10,
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter)
error_handler.addFilter(sensitive_filter)
error_handler.addFilter(request_id_filter)
app.logger.addHandler(error_handler)
# -------------------------------------------------------------------------
# 2. Authentication Log Handler
# -------------------------------------------------------------------------
auth_handler = RotatingFileHandler(
os.path.join(log_dir, 'auth.log'),
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
)
auth_handler.setLevel(logging.INFO)
auth_handler.setFormatter(formatter)
auth_handler.addFilter(sensitive_filter)
auth_handler.addFilter(request_id_filter)
# Create a named logger specifically for auth events
auth_logger = logging.getLogger('team_tryouts.auth')
auth_logger.setLevel(logging.INFO)
auth_logger.addHandler(auth_handler)
auth_logger.propagate = False # Don't double-log to root
# -------------------------------------------------------------------------
# 3. Application Log Handler (general)
# -------------------------------------------------------------------------
app_handler = RotatingFileHandler(
os.path.join(log_dir, 'app.log'),
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10,
)
app_handler.setLevel(log_level)
app_handler.setFormatter(formatter)
app_handler.addFilter(sensitive_filter)
app_handler.addFilter(request_id_filter)
app.logger.addHandler(app_handler)
# -------------------------------------------------------------------------
# 4. Console Handler (always on)
# -------------------------------------------------------------------------
# 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)
console_handler.addFilter(request_id_filter)
app.logger.addHandler(console_handler)
# -------------------------------------------------------------------------
# 5. Package logger ('app.*') — notably app.discord_bot
# -------------------------------------------------------------------------
# Modules using logging.getLogger(__name__) resolve to 'app.<module>'.
# 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)
app.logger.info('Application startup')
return app.logger
# Module-level auth logger factory
def get_auth_logger():
"""Get the authentication event logger.
Returns:
logging.Logger: Logger for authentication events.
"""
return logging.getLogger('team_tryouts.auth')
def log_auth_event(event, **fields):
"""Record a security-relevant event to auth.log.
The handler, its rotation and its redaction filter were configured from
the start, but get_auth_logger was never imported anywhere: auth.log was
created and stayed empty. No login, failure, lockout, role change or
account deletion left any trace.
Fields are emitted as `key=value` pairs, ordered, so the file stays
greppable without pulling in a JSON logging dependency.
Note on `ip`: it is taken from request.remote_addr, which reflects
X-Forwarded-For. As long as Waitress runs with trusted_proxy='*'
(SEC-WEB-002), that value is attacker-controlled and must be read as an
indication rather than as evidence.
Args:
event: Dotted event name, e.g. 'login.success'.
**fields: Additional context. Never pass a secret: values are
recorded verbatim apart from the redaction filter's patterns.
"""
from flask import has_request_context, request
parts = [f'event={event}']
if has_request_context():
parts.append(f'ip={request.remote_addr}')
parts.append(f'path={request.path}')
parts.extend(f'{key}={value}' for key, value in fields.items())
get_auth_logger().info(' '.join(parts))
+95
View File
@@ -0,0 +1,95 @@
"""All models — split into individual files for maintainability.
Import this module to register all models with SQLAlchemy and expose every
class, constant, and helper for use throughout the application.
Usage::
from app.models import User, Admin, Evaluation, ESPORT_GAMES, ...
Backward-compatible — no consumer changes needed.
"""
# =========================================================================
# Layer 0: constants (no app deps)
# =========================================================================
from app.models._constants import (
USER_TYPES,
ESPORT_GAMES,
GAME_POSITIONS,
GAME_PLATFORMS,
PLATFORM_CODES,
TRN_URLS,
)
# =========================================================================
# Layer 1: loaders & associations
# =========================================================================
from app.models._loaders import load_user # noqa: F401 — registers Flask-Login callback
# =========================================================================
# Layer 2: abstract base classes
# =========================================================================
from app.models.availability.base import BaseAvailability
from app.models.match_model.base import BaseMatch
from app.models.participant.base import BaseParticipant
# =========================================================================
# Layer 3: user hierarchy (polymorphic)
# =========================================================================
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.player import Player
from app.models.user_model.scout import Scout
# =========================================================================
# Layer 4: org_team + junction
# =========================================================================
from app.models.org_team.org_team import OrgTeam
from app.models.org_team.team_player import TeamPlayer
# =========================================================================
# Layer 5: concrete availability models
# =========================================================================
from app.models.availability.player_disponibility import PlayerDisponibility
from app.models.availability.coach_availability import CoachAvailability
# =========================================================================
# Layer 6: tryout + registration
# =========================================================================
from app.models.tryout.tryout import Tryout
from app.models.tryout.tryout_registration import TryoutRegistration
# =========================================================================
# Layer 7: evaluation
# =========================================================================
from app.models.evaluation import Evaluation
# =========================================================================
# Layer 8: tryout-specific teams
# =========================================================================
from app.models.team.team import Team
from app.models.team.team_member import TeamMember
# =========================================================================
# Layer 9: matches (tryout-scoped + regular-season)
# =========================================================================
from app.models.match_model.match import Match
from app.models.match_model.team_match import TeamMatch
# =========================================================================
# Layer 10: participants
# =========================================================================
from app.models.participant.match_participant import MatchParticipant
from app.models.participant.team_match_participant import TeamMatchParticipant
# =========================================================================
# Layer 11: remaining standalone models
# =========================================================================
from app.models.user_gamertag import UserGamertag
from app.models.contract import Contract
from app.models.team_note import TeamNote
from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest
+39
View File
@@ -0,0 +1,39 @@
"""Many-to-many association tables for OrgTeam ↔ User relationships."""
from app.extensions import db
org_team_coaches = db.Table(
'org_team_coaches',
db.Column(
'org_team_id',
db.Integer,
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
primary_key=True,
),
db.Column(
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
org_team_managers = db.Table(
'org_team_managers',
db.Column(
'org_team_id',
db.Integer,
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
primary_key=True,
),
db.Column(
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
tryout_coaches = db.Table(
'tryout_coaches',
db.Column(
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
),
db.Column(
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
+66
View File
@@ -0,0 +1,66 @@
"""Global constants shared by all model files.
Contains game lists, position mappings, platform codes, and TRN URL templates.
"""
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
ESPORT_GAMES = [
'Valorant',
'League of Legends',
'Counter-Strike 2',
'Apex Legends',
'Overwatch 2',
'Rainbow Six Siege',
'Rocket League',
'Super Smash Bros.',
]
GAME_POSITIONS = {
'League of Legends': ['Top Lane', 'Jungle', 'Mid Lane', 'ADC', 'Support'],
'Valorant': ['Controller', 'Initiator', 'Duelist', 'Sentinel', 'Flex'],
'Counter-Strike 2': ['AWPer', 'Entry Fragger', 'Lurker', 'In-Game Leader', 'Support'],
'Rainbow Six Siege': ['Entry', 'Support', 'Breacher', 'Anchor', 'Flex'],
'Overwatch 2': ['Tank', 'Damage', 'Support'],
'Apex Legends': [],
'Rocket League': [],
'Super Smash Bros.': [],
}
GAME_PLATFORMS = {
'Valorant': [],
'League of Legends': [],
'Counter-Strike 2': [],
'Apex Legends': ['PC', 'PlayStation', 'Xbox', 'Nintendo Switch'],
'Overwatch 2': [],
'Rainbow Six Siege': ['Ubisoft', 'PlayStation', 'Xbox'],
'Rocket League': ['Epic', 'PlayStation', 'Xbox', 'Nintendo Switch'],
'Super Smash Bros.': ['Nintendo Switch'],
}
PLATFORM_CODES = {
'Ubisoft': 'ubi',
'PlayStation': 'psn',
'Xbox': 'xbl',
'Nintendo Switch': 'switch',
'PC': 'pc',
'Steam': 'steam',
'Epic': 'epic',
}
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
'League of Legends': 'https://tracker.gg/lol/profile/{username}',
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}',
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
'Super Smash Bros.': 'https://tracker.gg/smash/profile/{username}',
}
+29
View File
@@ -0,0 +1,29 @@
"""Flask-Login user loader — registered with login_manager in models.py."""
from app.extensions import login_manager
@login_manager.user_loader
def load_user(user_id):
"""Load a user by ID for Flask-Login session management.
Returns the correct polymorphic subclass (Admin, Coach, Player, etc.)
automatically because SQLAlchemy resolves the identity column.
Returns None for deactivated accounts so that disabling a user also
invalidates the sessions they already hold. Flask-Login only consults
is_active when login_user() is called, never when restoring a session
from the cookie, so the check has to happen here.
"""
from app.extensions import db
from app.models.user_model.user import User
try:
pk = int(user_id)
except (TypeError, ValueError):
return None
user = db.session.get(User, pk)
if user is None or not user.is_active_account:
return None
return user
+7
View File
@@ -0,0 +1,7 @@
"""Availability models — BaseAvailability and its concrete subclasses."""
from app.models.availability.base import BaseAvailability
from app.models.availability.coach_availability import CoachAvailability
from app.models.availability.player_disponibility import PlayerDisponibility
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
+17
View File
@@ -0,0 +1,17 @@
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
from datetime import datetime
from app.extensions import db
class BaseAvailability(db.Model):
"""Shared schema for player disponibilities and coach availabilities."""
__abstract__ = True
day_of_week = db.Column(db.Integer, nullable=False)
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -0,0 +1,14 @@
"""Coach availability in 30-minute time blocks for One on One sessions."""
from app.extensions import db
from app.models.availability.base import BaseAvailability
class CoachAvailability(BaseAvailability):
"""Coach availability in 30-minute blocks for One on One sessions."""
__tablename__ = 'coach_availabilities'
id = db.Column(db.Integer, primary_key=True)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach = db.relationship('User', backref='coach_availabilities')
@@ -0,0 +1,14 @@
"""Player availability in 30-minute time blocks."""
from app.extensions import db
from app.models.availability.base import BaseAvailability
class PlayerDisponibility(BaseAvailability):
"""Player availability in 30-minute blocks."""
__tablename__ = 'player_disponibilities'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
player = db.relationship('User', backref='disponibilities')
+68
View File
@@ -0,0 +1,68 @@
"""Contract documents for players to sign."""
from datetime import datetime
from app.extensions import db
class Contract(db.Model):
"""Contract documents for players to sign."""
__tablename__ = 'contracts'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
uploaded_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
original_filename = db.Column(db.String(255), nullable=False)
stored_filename = db.Column(db.String(255), nullable=False)
file_path = db.Column(db.String(500), nullable=False)
signed_filename = db.Column(db.String(255), nullable=True)
signed_file_path = db.Column(db.String(500), nullable=True)
status = db.Column(db.String(20), default='pending')
notes = db.Column(db.Text, nullable=True)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
signed_at = db.Column(db.DateTime, nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
team = db.relationship('OrgTeam', foreign_keys=[team_id])
uploader = db.relationship('User', foreign_keys=[uploaded_by_id])
def can_view(self, user):
"""Whether this user may read the contract and download its files.
Two defects used to sit in the coach branch:
- `not self.team_id` acted as a wildcard, so any coach listed in the
legacy OrgTeam.coach_id column could read every contract with no
team attached — and upload_contract leaves team_id null whenever
the player belongs to no team.
- the lookup went through OrgTeam.coach_id only, so a coach attached
through the many-to-many relationship saw nothing at all.
Access now follows the same rule as everywhere else: the coach and
the player must actually work together.
"""
if user.id == self.player_id:
return True
from app.models.user_model.admin import Admin
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
if isinstance(user, Admin):
return True
if isinstance(user, Manager):
player = User.query.get(self.player_id)
if player and player.get_org_teams():
return True
if isinstance(user, Coach):
return coach_can_access_player(user, self.player_id)
return False
def can_upload_signed(self, user):
return user.id == self.player_id
+79
View File
@@ -0,0 +1,79 @@
"""Player evaluation record."""
from datetime import datetime
from app.extensions import db
class Evaluation(db.Model):
"""Player evaluation record."""
__tablename__ = 'evaluations'
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
evaluator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
mecanics_score = db.Column(db.Integer, nullable=True)
cohesion_score = db.Column(db.Integer, nullable=True)
communication_score = db.Column(db.Integer, nullable=True)
gamesense_score = db.Column(db.Integer, nullable=True)
versatility_score = db.Column(db.Integer, nullable=True)
discipline_score = db.Column(db.Integer, nullable=True)
analysis_score = db.Column(db.Integer, nullable=True)
sport_ethics_score = db.Column(db.Integer, nullable=True)
mental_score = db.Column(db.Integer, nullable=True)
overall_score = db.Column(db.Float, nullable=True)
comments = db.Column(db.Text, nullable=True)
position_recommendation = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
)
#: The nine criteria, in the order the form shows them. The overall score
#: is their mean; a criterion left blank is left out of the mean rather
#: than counted as a zero, which is why this list exists rather than the
#: route summing nine named variables (ARCH-005, QUA-003).
CRITERIA = (
'mecanics_score',
'cohesion_score',
'communication_score',
'gamesense_score',
'versatility_score',
'discipline_score',
'analysis_score',
'sport_ethics_score',
'mental_score',
)
@classmethod
def overall_from(cls, scores):
"""Mean of the criteria that were actually filled in.
Args:
scores: Mapping of criterion name to score or None.
Returns:
float | None: None when nothing was scored — which is not the
same as zero, and must not become one. A player nobody could
assess has no overall score; a player who scored zero on
everything cannot exist, the scale starts at one.
"""
given = [scores.get(name) for name in cls.CRITERIA]
given = [score for score in given if score is not None]
if not given:
return None
return sum(given) / len(given)
def apply_scores(self, scores):
"""Write these criteria onto the record and recompute the overall.
Every criterion is assigned, including the ones left blank: an edit
that clears a score has to clear it, and the mean has to be the mean
of what is on the record afterwards.
"""
for name in self.CRITERIA:
setattr(self, name, scores.get(name))
self.overall_score = self.overall_from(scores)
+7
View File
@@ -0,0 +1,7 @@
"""Match models — BaseMatch and its concrete subclasses."""
from app.models.match_model.base import BaseMatch
from app.models.match_model.match import Match
from app.models.match_model.team_match import TeamMatch
__all__ = ['BaseMatch', 'Match', 'TeamMatch']
+21
View File
@@ -0,0 +1,21 @@
"""Abstract base class for match models (Match + TeamMatch)."""
from datetime import datetime
from app.extensions import db
class BaseMatch(db.Model):
"""Shared schema for tryout-scoped matches and regular-season team matches."""
__abstract__ = True
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
date = db.Column(db.Date, nullable=False)
start_time = db.Column(db.Time, nullable=True)
end_time = db.Column(db.Time, nullable=True)
location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='scheduled')
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
+30
View File
@@ -0,0 +1,30 @@
"""Match / scrimmage within a tryout."""
from app.extensions import db
from app.models.match_model.base import BaseMatch
class Match(BaseMatch):
"""Match / scrimmage within a tryout."""
__tablename__ = 'matches'
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
match_type = db.Column(db.String(20), nullable=False)
team1_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
team2_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
creator = db.relationship('User', backref='created_matches')
tryout = db.relationship('Tryout', backref='matches')
team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1')
team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2')
# delete-orphan: without it, SQLAlchemy tries to detach participants by
# setting match_id to NULL, which the NOT NULL column refuses — so
# deleting any match that had participants raised IntegrityError.
# TeamMatch.participants already declared this; Match did not.
participants = db.relationship(
'MatchParticipant', backref='match', lazy='dynamic', cascade='all, delete-orphan'
)
def get_participating_players(self):
return [p.player_id for p in self.participants.all()]
+24
View File
@@ -0,0 +1,24 @@
"""Regular-season match for an organisation team (not tied to a tryout)."""
from app.extensions import db
from app.models.match_model.base import BaseMatch
class TeamMatch(BaseMatch):
"""Regular-season match for an organisation team (not tied to a tryout)."""
__tablename__ = 'team_matches'
id = db.Column(db.Integer, primary_key=True)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
opponent = db.Column(db.String(200), nullable=True)
org_team = db.relationship('OrgTeam', backref='team_matches')
creator = db.relationship('User', backref='created_team_matches')
participants = db.relationship(
'TeamMatchParticipant', backref='team_match', lazy='dynamic', cascade='all, delete-orphan'
)
def get_confirmed_count(self):
all_p = self.participants.all()
confirmed = sum(1 for p in all_p if p.is_confirmed)
return confirmed, len(all_p)
+28
View File
@@ -0,0 +1,28 @@
"""Request from player to coach for a One on One session."""
from datetime import datetime
from app.extensions import db
class OneOnOneRequest(db.Model):
"""Request from player to coach for a One on One session."""
__tablename__ = 'one_on_one_requests'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
date = db.Column(db.Date, nullable=False)
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
points = db.Column(db.Text, nullable=True)
status = db.Column(db.String(20), default='pending')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
responded_at = db.Column(db.DateTime, nullable=True)
discord_message_id = db.Column(db.BigInteger, nullable=True)
coach_rejection_message = db.Column(db.Text, nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests')
coach = db.relationship('User', foreign_keys=[coach_id])
team = db.relationship('OrgTeam', foreign_keys=[org_team_id])
+6
View File
@@ -0,0 +1,6 @@
"""Organisation team models."""
from app.models.org_team.org_team import OrgTeam
from app.models.org_team.team_player import TeamPlayer
__all__ = ['OrgTeam', 'TeamPlayer']
+68
View File
@@ -0,0 +1,68 @@
"""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
class OrgTeam(db.Model):
"""Persistent organisation team (e.g. Varsity, JV)."""
__tablename__ = 'org_teams'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
creator = db.relationship('User', foreign_keys=[created_by])
coaches = db.relationship(
'User',
secondary=org_team_coaches,
lazy='dynamic',
backref=db.backref('coached_org_teams', lazy='dynamic'),
)
managers = db.relationship(
'User',
secondary=org_team_managers,
lazy='dynamic',
backref=db.backref('managed_org_teams', lazy='dynamic'),
)
coach = db.relationship(
'User',
foreign_keys=[coach_id],
backref=db.backref('coached_org_team_legacy', uselist=False),
viewonly=True,
)
manager = db.relationship(
'User',
foreign_keys=[manager_id],
backref=db.backref('managed_org_team_legacy', uselist=False),
viewonly=True,
)
def get_coaches(self):
coach_list = self.coaches.all()
if not coach_list and self.coach:
return [self.coach]
return coach_list
def get_managers(self):
manager_list = self.managers.all()
if not manager_list and self.manager:
return [self.manager]
return manager_list
@property
def players(self):
return [tp.player for tp in self.team_players]
def get_players_with_status(self):
return [
{'player': tp.player, 'status': tp.status, 'position': tp.position}
for tp in self.team_players
]
+24
View File
@@ -0,0 +1,24 @@
"""Many-to-many junction: player to org-team."""
from datetime import datetime
from app.extensions import db
class TeamPlayer(db.Model):
"""Many-to-many: player to org-team."""
__tablename__ = 'team_players'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
status = db.Column(db.String(20), nullable=False, default='starter')
position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
__table_args__ = (
db.UniqueConstraint('player_id', 'org_team_id', name='unique_player_org_team'),
)
+7
View File
@@ -0,0 +1,7 @@
"""Participant models — BaseParticipant and its concrete subclasses."""
from app.models.participant.base import BaseParticipant
from app.models.participant.match_participant import MatchParticipant
from app.models.participant.team_match_participant import TeamMatchParticipant
__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant']
+14
View File
@@ -0,0 +1,14 @@
"""Abstract base class for match participant models."""
from datetime import datetime
from app.extensions import db
class BaseParticipant(db.Model):
"""Shared schema for match participants."""
__abstract__ = True
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -0,0 +1,17 @@
"""Participant in a tryout-scoped match."""
from app.extensions import db
from app.models.participant.base import BaseParticipant
class MatchParticipant(BaseParticipant):
"""Participant in a tryout-scoped match."""
__tablename__ = 'match_participants'
id = db.Column(db.Integer, primary_key=True)
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
team_side = db.Column(db.Integer, nullable=True)
position = db.Column(db.String(50), nullable=True)
attendance_confirmed = db.Column(db.Boolean, default=False)
player = db.relationship('User')
@@ -0,0 +1,15 @@
"""Participant in a regular-season team match."""
from app.extensions import db
from app.models.participant.base import BaseParticipant
class TeamMatchParticipant(BaseParticipant):
"""Participant in a regular-season team match."""
__tablename__ = 'team_match_participants'
id = db.Column(db.Integer, primary_key=True)
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
is_confirmed = db.Column(db.Boolean, default=False)
player = db.relationship('User')
+27
View File
@@ -0,0 +1,27 @@
"""Personal notes from coach to individual player."""
from datetime import datetime
from app.extensions import db
class PersonalNote(db.Model):
"""Personal notes from coach to individual player."""
__tablename__ = 'personal_notes'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='personal_notes')
coach = db.relationship('User', foreign_keys=[coach_id])
match = db.relationship('Match', foreign_keys=[match_id])
team = db.relationship('Team', foreign_keys=[team_id])
tryout = db.relationship('Tryout', foreign_keys=[tryout_id])
+6
View File
@@ -0,0 +1,6 @@
"""Tryout-specific temporary team models."""
from app.models.team.team import Team
from app.models.team.team_member import TeamMember
__all__ = ['Team', 'TeamMember']
+19
View File
@@ -0,0 +1,19 @@
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
from datetime import datetime
from app.extensions import db
class Team(db.Model):
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
__tablename__ = 'teams'
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
name = db.Column(db.String(100), nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
creator = db.relationship('User', backref='created_teams')
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
+18
View File
@@ -0,0 +1,18 @@
"""Link between a player and a tryout-specific team."""
from datetime import datetime
from app.extensions import db
class TeamMember(db.Model):
"""Link between a player and a tryout-specific team."""
__tablename__ = 'team_members'
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
player = db.relationship('User', overlaps="player_ref,team_assignments")
+20
View File
@@ -0,0 +1,20 @@
"""Team improvement notes from coach."""
from datetime import datetime
from app.extensions import db
class TeamNote(db.Model):
"""Team improvement notes from coach."""
__tablename__ = 'team_notes'
id = db.Column(db.Integer, primary_key=True)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
team = db.relationship('OrgTeam', backref='team_notes')
coach = db.relationship('User', foreign_keys=[coach_id])
+6
View File
@@ -0,0 +1,6 @@
"""Tryout models."""
from app.models.tryout.tryout import Tryout
from app.models.tryout.tryout_registration import TryoutRegistration
__all__ = ['Tryout', 'TryoutRegistration']
+50
View File
@@ -0,0 +1,50 @@
"""Tryout event for player evaluations and team formation."""
from datetime import datetime
from app.extensions import db
from app.models._associations import tryout_coaches
class Tryout(db.Model):
"""Tryout event for player evaluations and team formation."""
__tablename__ = 'tryouts'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
game = db.Column(db.String(50), nullable=False)
date = db.Column(db.Date, nullable=False)
end_date = db.Column(db.Date, nullable=True)
location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='upcoming')
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)
coach_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration
created_at = db.Column(db.DateTime, default=datetime.utcnow)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
target_org_team = db.relationship(
'OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]
)
@property
def is_ended(self):
"""Tryout is considered ended after its end_date passes.
Falls back to date if end_date is not set."""
from datetime import date as date_type
today = date_type.today()
if self.end_date is not None:
return self.end_date < today
return self.date < today
+17
View File
@@ -0,0 +1,17 @@
"""Registration linking a player to a tryout."""
from datetime import datetime
from app.extensions import db
class TryoutRegistration(db.Model):
"""Registration linking a player to a tryout."""
__tablename__ = 'tryout_registrations'
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
status = db.Column(db.String(20), default='registered')
notes = db.Column(db.Text, nullable=True)
+45
View File
@@ -0,0 +1,45 @@
"""Store gamertag per game for each user."""
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."""
__tablename__ = 'user_gamertags'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
game = db.Column(db.String(50), nullable=False)
gamertag = db.Column(db.String(120), nullable=False)
platform = db.Column(db.String(30), nullable=True)
user = db.relationship('User', backref='gamertags')
__table_args__ = (db.UniqueConstraint('user_id', 'game', name='unique_user_game'),)
def get_trn_url(self):
if self.game not in TRN_URLS:
return None
url = TRN_URLS[self.game]
encoded_gamertag = quote(self.gamertag, safe='')
# Resolve platform: use user's selection, or fall back to game default
platform = self.platform
if not platform:
platform = PLATFORM_DEFAULTS.get(self.game, '')
if '{platform_code}' in url and '{username}' in url:
platform_code = PLATFORM_CODES.get(
platform,
platform.lower().replace(' ', '-') if platform else '',
)
return url.format(platform_code=platform_code, username=encoded_gamertag)
if '{platform}' in url and '{username}' in url:
return url.format(
platform=platform.lower().replace(' ', '-') if platform else '',
username=encoded_gamertag,
)
if '{username}' in url:
return url.format(username=encoded_gamertag)
return url
+10
View File
@@ -0,0 +1,10 @@
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout)."""
from app.models.user_model.admin import Admin
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']
+35
View File
@@ -0,0 +1,35 @@
"""Admin / President — full access to everything."""
from app.models.user_model.user import User
class Admin(User):
"""President / super-admin — full access to everything."""
__mapper_args__ = {'polymorphic_identity': 'admin'}
def can_evaluate(self):
return True
def can_manage_users(self):
return True
def can_manage_teams(self):
return True
def can_manage_tryouts(self):
return True
def can_schedule_matches(self):
return True
def can_manage_this_tryout(self, tryout):
return True
def can_manage_this_org_team(self, org_team):
return True
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
return Tryout.query.order_by(Tryout.date).all()
+42
View File
@@ -0,0 +1,42 @@
"""Coach — evaluates, schedules matches, manages their own org teams."""
from app.models.user_model.user import User
class Coach(User):
"""Coach — evaluates, schedules matches, manages their own org teams.
Every question about *which* teams or tryouts belong to this coach is
delegated to ``app.permissions``. Two of the three methods below used to
answer it themselves, each considering a different subset of the two
ways a coach can be attached to a team: ``can_manage_this_tryout``
ignored the legacy ``coach_id`` column when checking the target team,
and ``get_visible_tryouts`` ignored it entirely. A coach attached only
by that column therefore saw an empty calendar (ARCH-002).
"""
__mapper_args__ = {'polymorphic_identity': 'coach'}
def can_evaluate(self):
return True
def can_schedule_matches(self):
return True
def can_manage_tryouts(self):
return True
def can_manage_this_tryout(self, tryout):
from app.permissions import coach_manages_tryout
return coach_manages_tryout(self, tryout)
def can_manage_this_org_team(self, org_team):
if org_team.coaches.filter_by(id=self.id).first():
return True
return org_team.coach_id == self.id
def get_visible_tryouts(self):
from app.permissions import coach_tryouts
return coach_tryouts(self)
+38
View File
@@ -0,0 +1,38 @@
"""Manager — manages own tryouts, all org teams, all contracts."""
from app.models.user_model.user import User
class Manager(User):
"""Manager — manages own tryouts, all org teams, all contracts."""
__mapper_args__ = {'polymorphic_identity': 'manager'}
def can_evaluate(self):
return True
def can_manage_teams(self):
return True
def can_manage_tryouts(self):
return True
def can_schedule_matches(self):
return True
def can_manage_this_tryout(self, tryout):
return tryout.created_by == self.id or tryout.manager_id == self.id
def can_manage_this_org_team(self, org_team):
return True
def get_visible_tryouts(self):
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)
.all()
)
+44
View File
@@ -0,0 +1,44 @@
"""Player — registers for tryouts, manages their own profile."""
from app.models.user_model.user import User
class Player(User):
"""Player — registers for tryouts, manages their own profile."""
__mapper_args__ = {'polymorphic_identity': 'player'}
def get_visible_tryouts(self):
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()]
tryouts = (
Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all()
if player_tryout_ids
else []
)
# plus tryouts where they participate in a match
player_matches = (
Match.query.join(MatchParticipant)
.filter(
MatchParticipant.player_id == self.id,
)
.all()
)
extra_ids = {m.tryout_id for m in player_matches}
extra = (
Tryout.query.filter(
Tryout.id.in_(extra_ids),
)
.order_by(Tryout.date)
.all()
if extra_ids
else []
)
all_ids = {t.id for t in tryouts}
return tryouts + [t for t in extra if t.id not in all_ids]
+17
View File
@@ -0,0 +1,17 @@
"""Scout — view-only access to tryouts and evaluations."""
from app.models.user_model.user import User
class Scout(User):
"""Scout — view-only access to tryouts and evaluations."""
__mapper_args__ = {'polymorphic_identity': 'scout'}
def can_evaluate(self):
return True
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
return Tryout.query.order_by(Tryout.date).all()
+108
View File
@@ -0,0 +1,108 @@
"""Base User model — shared fields and polymorphic configuration."""
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.
Do not instantiate this class directly; use Admin, Manager, Coach, Player,
or Scout so that `polymorphic_identity` is set correctly.
"""
__tablename__ = 'users'
# --- columns -----------------------------------------------------------
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(20), nullable=False, default='player') # polymorphic discriminator
full_name = db.Column(db.String(100), nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
phone = db.Column(db.String(20), nullable=True)
is_active_account = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
failed_login_attempts = db.Column(db.Integer, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
# E-Sports fields
games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player)
discord_username = db.Column(db.String(128), nullable=True)
discord_user_id = db.Column(db.String(64), nullable=True)
league_os_profile = db.Column(db.String(256), nullable=True)
# --- polymorphic configuration -----------------------------------------
__mapper_args__ = {
'polymorphic_identity': 'user',
'polymorphic_on': role,
}
# --- relationships (defined once on the base) --------------------------
evaluations_given = db.relationship(
'Evaluation', foreign_keys='Evaluation.evaluator_id', backref='evaluator', lazy='dynamic'
)
evaluations_received = db.relationship(
'Evaluation', foreign_keys='Evaluation.player_id', backref='player', lazy='dynamic'
)
tryout_registrations = db.relationship('TryoutRegistration', backref='player', lazy='dynamic')
team_assignments = db.relationship(
'TeamMember', foreign_keys='TeamMember.player_id', backref='player_ref', lazy='dynamic'
)
# --- Flask-Login integration -------------------------------------------
@property
def is_active(self):
"""Whether Flask-Login should accept this account.
UserMixin returns True unconditionally, which meant a deactivated
account kept any session it already held. Binding this to
is_active_account makes deactivation take effect on the next request.
"""
return bool(self.is_active_account)
# --- shared helper methods ---------------------------------------------
def get_games_list(self):
"""Return the user's games as a list."""
if self.games:
return [g.strip() for g in self.games.split(',') if g.strip()]
return []
def get_gamertags(self):
"""Return gamertags as a dict keyed by game."""
return {
gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in self.gamertags
}
def get_org_teams(self):
"""Return all OrgTeams this player belongs to."""
return [tp.org_team for tp in self.team_placements]
# --- stubs (overridden in subclasses) ----------------------------------
def can_evaluate(self):
return False
def can_manage_users(self):
return False
def can_manage_teams(self):
return False
def can_manage_tryouts(self):
return False
def can_schedule_matches(self):
return False
def can_manage_this_tryout(self, tryout):
return False
def can_manage_this_org_team(self, org_team):
return False
def get_visible_tryouts(self):
return []
+47 -11
View File
@@ -110,7 +110,8 @@ http {
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" 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 Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Opener-Policy "same-origin" always;
@@ -118,8 +119,17 @@ http {
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# Proxy to Waitress (Flask) # Proxy to Waitress (Flask)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
#
# 10000 is `PORT`'s default in wsgi.py, which is what serves this
# application in production. This line said 5000 run.py's default,
# the development server so anyone installing this file as shipped
# got 502 Bad Gateway on every page, from a configuration that looks
# entirely reasonable (STD-06).
#
# If PORT is set in the server's .env, this must match it.
# tests/test_nginx_config.py fails if this drifts from wsgi.py again.
location / { location / {
proxy_pass http://127.0.0.1:5000; proxy_pass http://127.0.0.1:10000;
# Proxy headers # Proxy headers
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -142,15 +152,41 @@ http {
} }
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# Static Files (served directly by Nginx for performance) # Static Files (PERF-006)
# Uncomment and adjust path if you want Nginx to serve static files #
# 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=<mtime> (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/ { location /static/ {
# alias C:/path/to/team-tryouts/static/; alias C:/team-tryouts/app/static/;
# expires 30d; expires 30d;
# add_header Cache-Control "public, immutable"; access_log off;
# 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 # Rate Limiting
@@ -161,7 +197,7 @@ http {
# location /auth/login { # location /auth/login {
# limit_req zone=login burst=5 nodelay; # limit_req zone=login burst=5 nodelay;
# proxy_pass http://127.0.0.1:5000; # proxy_pass http://127.0.0.1:10000;
# } # }
} }
} }
+64
View File
@@ -0,0 +1,64 @@
"""Bounding what a list view loads (MNT-14).
Every list view ran `.all()` on its table and handed the whole thing to a
template. The audit rated the impact as nil — correctly, at the scale of a
student club — and recommended choosing the pattern now rather than
retro-fitting one later. This is that pattern, in one place, so that the
next list added to the application has something to copy.
Two decisions worth stating, because both are the kind that get made twice
differently otherwise.
**`error_out=False`.** Page numbers arrive in the URL, so `?page=999` is a
thing a person can type or a stale bookmark can hold. Flask-SQLAlchemy's
default answers it with a 404, which is a confusing thing to show someone
who has simply gone one page too far. An empty page is honest and the
controls take them back.
**A cap on `per_page`.** It is also a URL parameter, and without a ceiling
`?per_page=100000` re-creates by hand exactly the unbounded query this
module exists to prevent — the sort of thing that turns a listing into a
cheap way to make the server work hard.
"""
from flask import request, url_for
#: Rows per page when nothing asks otherwise.
DEFAULT_PER_PAGE = 50
#: Ceiling on the `per_page` query parameter. Generous enough that anyone
#: wanting "everything" on one screen gets it for any realistic table, low
#: enough that the query stays bounded.
MAX_PER_PAGE = 200
def paginate(query, per_page=DEFAULT_PER_PAGE):
"""Return one page of `query`, honouring `?page=` and `?per_page=`.
Args:
query: A SQLAlchemy query, already ordered. Ordering matters: a
paginated query without ORDER BY may return the same row on two
pages and never return another.
per_page: Default page size for this listing.
Returns:
flask_sqlalchemy.pagination.Pagination
"""
page = request.args.get('page', 1, type=int) or 1
requested = request.args.get('per_page', per_page, type=int) or per_page
size = max(1, min(requested, MAX_PER_PAGE))
return query.paginate(page=max(1, page), per_page=size, error_out=False)
def page_url(page):
"""URL of the current listing at another page number.
Rebuilt from the live request rather than composed in the template,
because the part that gets forgotten is the rest of the query string:
the evaluations list carries `sort` and `order`, the team matches list
carries `team_id`. A pagination link that drops them silently resets the
view the person was looking at.
"""
args = request.args.to_dict()
args.pop('page', None)
return url_for(request.endpoint, page=page, **(request.view_args or {}), **args)
+345
View File
@@ -0,0 +1,345 @@
"""Shared access-control rules — the single point of truth (ARCH-002).
Authorisation used to live inline in eight route modules, and the same
question could get a different answer depending on which URL you reached.
This module now holds the rules themselves; routes and models call it.
The subtlety it hides from callers: a coach or a manager can be attached to
a team two different ways.
OrgTeam.coach_id the original single-coach column
OrgTeam.coaches the many-to-many relationship added later
Both are still populated. Reading only ``coach_id`` — which most of
users.py did — silently locked out every coach who was not the first one on
their team, and reading ``.first()`` on top of it locked a coach out of
every team but one. Both defects were live in production. Every function
here considers both attachment routes and every team, so the fix applies
once instead of at each call site.
ARCH-001 will collapse the two columns into one for good; that needs a data
migration, so until then this module is what makes the duplication
harmless.
Functions take the acting user explicitly rather than reading
``current_user``: it keeps them callable from models, from the Discord bot,
and from tests without a request context.
"""
from app.extensions import db
# ---------------------------------------------------------------------------
# Team attachment
# ---------------------------------------------------------------------------
def coach_org_teams(coach):
"""Organisation teams a coach is attached to, ordered by name.
Considers the many-to-many relationship *and* the legacy column, so the
second coach of a team is not treated as belonging to nothing.
Args:
coach: The user to inspect.
Returns:
list[OrgTeam]: Possibly empty.
"""
from app.models import OrgTeam
return (
OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=coach.id),
OrgTeam.coach_id == coach.id,
)
)
.order_by(OrgTeam.name)
.all()
)
def coach_org_team_ids(coach):
"""IDs of the organisation teams a coach is attached to.
Args:
coach: The user to inspect.
Returns:
list[int]: Team IDs, possibly empty.
"""
return [team.id for team in coach_org_teams(coach)]
def manager_org_teams(manager):
"""Organisation teams a manager is attached to, ordered by name.
Symmetric to :func:`coach_org_teams`: ``manager_id`` and ``managers``
carry the same duplication.
Args:
manager: The user to inspect.
Returns:
list[OrgTeam]: Possibly empty.
"""
from app.models import OrgTeam
return (
OrgTeam.query.filter(
db.or_(
OrgTeam.managers.any(id=manager.id),
OrgTeam.manager_id == manager.id,
)
)
.order_by(OrgTeam.name)
.all()
)
def attached_org_teams(user):
"""Teams the user is personally attached to, whatever their role.
A president is attached to none in particular — they administer them
all — so this returns an empty list for them. Callers that want "the
teams to display" want :func:`visible_org_teams` instead.
Args:
user: The acting user.
Returns:
list[OrgTeam]: Possibly empty.
"""
from app.models import Coach, Manager, Player
if isinstance(user, Coach):
return coach_org_teams(user)
if isinstance(user, Manager):
return manager_org_teams(user)
if isinstance(user, Player):
return user.get_org_teams()
return []
def visible_org_teams(user):
"""Organisation teams the user may see, ordered by name.
A president sees every team; a coach or a manager sees the ones they
are attached to; a player sees the ones they play on; anyone else sees
none.
Args:
user: The acting user.
Returns:
list[OrgTeam]: Possibly empty.
"""
from app.models import Admin, OrgTeam
if isinstance(user, Admin):
return OrgTeam.query.order_by(OrgTeam.name).all()
return attached_org_teams(user)
def can_manage_org_team(user, org_team):
"""Whether the user may administer this organisation team.
Delegates to the polymorphic model method, which is the role-level
rule; this wrapper exists so route code has one name to call and never
has to know which subclass it is holding.
Args:
user: The acting user.
org_team: The team concerned.
Returns:
bool
"""
return bool(org_team) and user.can_manage_this_org_team(org_team)
# ---------------------------------------------------------------------------
# Reach over players
# ---------------------------------------------------------------------------
def org_team_player_ids(team_ids):
"""IDs of the players placed on any of these teams.
Args:
team_ids: Team primary keys.
Returns:
list[int]: Player IDs, possibly empty, without duplicates.
"""
from app.models import TeamPlayer
if not team_ids:
return []
rows = TeamPlayer.query.filter(TeamPlayer.org_team_id.in_(team_ids)).all()
return list({row.player_id for row in rows})
def coach_player_ids(coach):
"""IDs of the players on *all* of a coach's teams.
The routes this replaces looked at one team — the first row matching
the legacy column — so a coach of two teams could act on half of their
squad and no more.
Args:
coach: The acting coach.
Returns:
list[int]: Player IDs, possibly empty.
"""
return org_team_player_ids(coach_org_team_ids(coach))
def coach_can_access_player(coach, player_id):
"""Whether a coach may read or write information about a player.
True when the player sits on one of the coach's teams, or takes part in
a tryout the coach manages. Anything else means the two have no working
relationship, and a note or a contract about that player is none of the
coach's business.
Args:
coach: The acting coach.
player_id: Primary key of the player concerned.
Returns:
bool
"""
from app.models import Match, MatchParticipant, TeamPlayer, TryoutRegistration
if not player_id:
return False
team_ids = coach_org_team_ids(coach)
if team_ids:
on_team = TeamPlayer.query.filter(
TeamPlayer.player_id == player_id,
TeamPlayer.org_team_id.in_(team_ids),
).first()
if on_team:
return True
tryout_ids = coach_tryout_ids(coach, team_ids=team_ids)
if not tryout_ids:
return False
registered = TryoutRegistration.query.filter(
TryoutRegistration.player_id == player_id,
TryoutRegistration.tryout_id.in_(tryout_ids),
).first()
if registered:
return True
plays_a_match = (
MatchParticipant.query.join(Match)
.filter(
MatchParticipant.player_id == player_id,
Match.tryout_id.in_(tryout_ids),
)
.first()
)
return plays_a_match is not None
def can_manage_player_contract(user, player_id):
"""Whether the user may upload or replace a contract for this player.
Presidents and managers may do so for anyone. A coach may do so for the
players on their teams — the working relationship a contract implies.
Args:
user: The acting user.
player_id: Primary key of the player concerned.
Returns:
bool
"""
from app.models import Admin, Coach, Manager
if not player_id:
return False
if isinstance(user, (Admin, Manager)):
return True
if isinstance(user, Coach):
return player_id in coach_player_ids(user)
return False
# ---------------------------------------------------------------------------
# Tryouts
# ---------------------------------------------------------------------------
def coach_tryouts(coach, team_ids=None):
"""Tryouts a coach manages, ordered by date.
A coach reaches a tryout through any of the three routes the model
supports: it targets one of their teams, they are named on the
many-to-many relationship, or the deprecated ``coach_id`` points at
them.
Args:
coach: The acting coach.
team_ids: Pre-computed team IDs, to avoid querying twice when the
caller already has them.
Returns:
list[Tryout]: Possibly empty.
"""
from app.models import Tryout
if team_ids is None:
team_ids = coach_org_team_ids(coach)
conditions = [
Tryout.coaches.any(id=coach.id),
Tryout.coach_id == coach.id,
]
if team_ids:
conditions.append(Tryout.target_org_team_id.in_(team_ids))
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
def coach_tryout_ids(coach, team_ids=None):
"""IDs of the tryouts a coach manages.
Args:
coach: The acting coach.
team_ids: Pre-computed team IDs, see :func:`coach_tryouts`.
Returns:
list[int]: Possibly empty.
"""
return [tryout.id for tryout in coach_tryouts(coach, team_ids=team_ids)]
def coach_manages_tryout(coach, tryout):
"""Whether a coach manages this particular tryout.
Same three routes as :func:`coach_tryouts`, asked about one row.
Written as a membership test rather than a query so that a tryout not
yet flushed to the database still answers correctly.
Args:
coach: The acting coach.
tryout: The tryout concerned.
Returns:
bool
"""
if tryout is None:
return False
if tryout.coach_id == coach.id:
return True
if any(c.id == coach.id for c in tryout.coaches):
return True
if tryout.target_org_team_id:
return tryout.target_org_team_id in coach_org_team_ids(coach)
return False
+704
View File
@@ -0,0 +1,704 @@
"""Authentication routes for user login, logout, and registration.
This module handles user authentication including login with account lockout
protection, logout with session clearing, and new user registration with
password policy enforcement and sign-up screening.
"""
import os
import secrets
import time
from datetime import datetime, timedelta
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 app.models import ESPORT_GAMES, Player, User
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
#: Session key holding the pending OAuth2 anti-forgery token.
DISCORD_STATE_KEY = 'discord_oauth_state'
#: Whether the OAuth result should create a registration draft or relink the
#: signed-in account. Kept server-side and covered by the same signed session
#: as the anti-forgery state.
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
# Failed-attempt tracking. The tally is kept for the audit trail and for the
# cool-off marker below; it no longer refuses a correct password (SEC-018).
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
#: Ceiling on the doubling cool-off window.
MAX_LOCKOUT_MINUTES = 240
#: Hash of a value nobody can submit. Verifying against it when the username
#: is unknown makes that path cost the same scrypt work as a real one, so the
#: 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')
DISCORD_REDIRECT_URI = os.getenv('DISCORD_REDIRECT_URI')
DISCORD_API_BASE = 'https://discord.com/api/v10'
# Mapping from Discord connection platform to E-Sports games
DISCORD_PLATFORM_TO_GAMES = {
'steam': ['Counter-Strike 2'],
'battlenet': ['Overwatch 2'],
'epicgames': ['Rocket League'],
'xbox': ['Apex Legends', 'Rainbow Six Siege', 'Rocket League'],
'playstation': ['Apex Legends', 'Rainbow Six Siege', 'Rocket League'],
}
def is_safe_url(url):
"""Validate that a URL is safe for redirection (same origin).
Accepts an absolute URL on this host, or a path beginning with exactly
one slash. Everything else is refused, including the two forms that
read differently to urlparse and to a browser:
/\\evil.com several browsers normalise the backslash to a slash,
turning this into the protocol-relative //evil.com.
urlparse reports no netloc at all, so the old check
let it through and the redirect left the site.
/\\n//evil.com control characters are stripped before parsing.
Args:
url: The URL to validate.
Returns:
bool: True if the URL is safe.
"""
if not url:
return False
if any(ord(char) < 0x20 or char in '\\\x7f' for char in url):
return False
parsed = urlparse(url)
if parsed.netloc:
return parsed.netloc == request.host and parsed.scheme in ('', 'http', 'https')
# Relative targets must be rooted. 'dashboard' or 'javascript:...' are
# not paths on this site.
return url.startswith('/')
def _absent_user_hash():
"""A hash to verify against when the submitted username does not exist.
check_password() used to be reached only when a user row was found, so
an unknown username answered as fast as the database lookup, and a known
one as slowly as scrypt. The gap is measurable and enumerates accounts.
Computed once per process, from a random secret, so no submitted password
can ever match it.
"""
global _ABSENT_USER_HASH
if _ABSENT_USER_HASH is None:
_ABSENT_USER_HASH = hash_password(secrets.token_urlsafe(32))
return _ABSENT_USER_HASH
def cooloff_minutes(failed_attempts):
"""Length of the cool-off window earned by this many failed attempts.
Doubles every MAX_LOGIN_ATTEMPTS further failures, up to a ceiling.
Args:
failed_attempts: Consecutive failures recorded on the account.
Returns:
int: Minutes.
"""
steps = max(failed_attempts // MAX_LOGIN_ATTEMPTS - 1, 0)
return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES)
def issue_registration_challenge():
"""Mark that the registration form has been handed out, and when.
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:
str | None: a short reason for the log, or None to let it through.
"""
if form.get(REGISTRATION_HONEYPOT_FIELD, '').strip():
return 'honeypot'
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')
@auth_bp.route('/login', methods=['GET', 'POST'])
@limiter.limit("10 per minute")
def login():
"""Handle user login authentication.
GET: Render the login form.
POST: Authenticate user credentials, with audit logging.
Failed attempts are counted and open a cool-off window, recorded in
``locked_until`` and in the authentication log. The window does not
refuse correct credentials: when it did, five wrong guesses against a
known username took that account out of service for fifteen minutes,
repeatably, and on a president's account that meant no administration
at all. Guess rate is bounded by the rate limit on this view.
Returns:
Response: Login form or redirect to dashboard/next page.
"""
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
# Validate input with marshmallow schema
login_schema = LoginSchema()
try:
validated = login_schema.load(request.form)
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return render_template('pages/login.html')
username = validated['username']
password = validated['password']
user = User.query.filter_by(username=username).first()
# Verified before anything else is decided, and on both branches.
# Reaching this only when a row exists made the response time a
# reliable oracle for which usernames are registered (SEC-017).
credentials_ok = check_password(
user.password_hash if user else _absent_user_hash(), password
)
if user and credentials_ok:
if not user.is_active_account:
log_auth_event('login.rejected.deactivated', username=username, user_id=user.id)
flash(_('This account has been deactivated.'), 'danger')
return render_template('pages/login.html')
# Correct credentials clear the tally, cool-off window included.
# The window used to refuse them too, which is what turned it
# into a way to lock a known account out at will (SEC-018).
user.failed_login_attempts = 0
user.locked_until = None
db.session.commit()
# Clear old session data and preserve CSRF token to prevent
# session fixation attacks (Flask-Login rotates the session ID).
#
# The language choice is carried across too. Someone who reads the
# login page in English and signs in would otherwise be dropped
# back into French — the preference lives in the session, and
# clearing it discards a decision the user just made.
_preserved = {
key: session[key] for key in ('csrf_token', LOCALE_SESSION_KEY) if key in session
}
session.clear()
session.update(_preserved)
# Mark the session permanent so PERMANENT_SESSION_LIFETIME applies.
# Without this, Flask emits a browser-session cookie with no expiry
# and the configured lifetime is silently ignored.
session.permanent = True
login_user(user)
log_auth_event('login.success', username=user.username, user_id=user.id, role=user.role)
# Validate redirect URL to prevent open redirect vulnerability
next_page = request.args.get('next')
if next_page and not is_safe_url(next_page):
next_page = None
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
# One message for every failure. The old code said "N attempts
# remaining" to a real account and "check username and password"
# to an unknown one, which listed the club's accounts to anyone
# who asked (SEC-017).
if user:
user.failed_login_attempts += 1
log_auth_event(
'login.failure',
username=username,
user_id=user.id,
attempts=user.failed_login_attempts,
)
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
minutes = cooloff_minutes(user.failed_login_attempts)
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
log_auth_event(
'account.throttled',
username=username,
user_id=user.id,
minutes=minutes,
attempts=user.failed_login_attempts,
)
db.session.commit()
else:
log_auth_event('login.failure.unknown_user', username=username)
flash(
_(
'Login unsuccessful. Please check your username and '
'password, or ask a president for help.'
),
'danger',
)
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.
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.
Returns:
Response: Registration form or redirect to login.
"""
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
# Build form data from request to preserve state across re-renders
form_data = dict(request.form)
form_data['games'] = request.form.getlist('games')
# Once Discord has authenticated the identity, neither its display
# name nor its snowflake is input data anymore. Remove any client
# copies before validation as well as before persistence: otherwise a
# forged, malformed hidden value can still make the verified flow fail.
discord_oauth = session.get('discord_oauth') or {}
if discord_oauth.get('id'):
form_data.pop('discord_username', None)
form_data.pop('discord_user_id', None)
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()
try:
validated = register_schema.load(form_data)
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return _rerender_registration(form_data)
username = validated['username']
email = validated['email']
password = validated['password']
full_name = validated['full_name']
phone = validated.get('phone')
selected_games = validated.get('games', [])
# The OAuth identity is server-side state. It used to be copied into
# hidden inputs and read back from request.form, which let anyone
# replace the verified Discord account before submitting (SEC-AUTH-005).
# A manual registration may still provide a display name, but never a
# Discord snowflake: that identifier is an authentication factor for
# bot reactions and must come from Discord itself.
discord_user_id = discord_oauth.get('id')
if discord_user_id:
discord_user_id = str(discord_user_id)
discord_username = (
discord_oauth.get('username') if discord_user_id else validated.get('discord_username')
)
league_os_profile = validated.get('league_os_profile')
if User.query.filter_by(username=username).first():
flash(_('Username already exists.'), 'danger')
return _rerender_registration(form_data)
if User.query.filter_by(email=email).first():
flash(_('Email already registered.'), 'danger')
return _rerender_registration(form_data)
# The database constraint belongs to DB-002, after production has
# been backed up and deduplicated. Refuse new duplicates now instead
# of leaving the critical impersonation path open until then.
if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender_registration(form_data)
hashed_password = hash_password(password)
user = Player(
username=username,
password_hash=hashed_password,
role='player',
full_name=full_name,
email=email,
phone=phone,
games=','.join(selected_games) if selected_games else None,
discord_username=discord_username,
discord_user_id=discord_user_id,
league_os_profile=league_os_profile,
)
db.session.add(user)
# flush, not commit: the id is needed for the gamertag rows below,
# and signing up is one operation. Committing here made it two, so a
# failure while writing the gamertags left an account whose declared
# games were silently absent (ARCH-006).
db.session.flush()
# Create UserGamertag records for each selected game
from app.models import UserGamertag
for game in selected_games:
field_name = f'gamertag_{game}'
gamertag_value = request.form.get(field_name, '').strip()
if gamertag_value:
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_value,
)
db.session.add(gamertag)
db.session.commit()
# 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)
flash(_('Your account has been created! You can now log in.'), 'success')
return redirect(url_for('auth.login'))
# GET request — render empty form
issue_registration_challenge()
return _rerender_registration({})
@auth_bp.route('/discord/login')
def discord_login():
"""Redirect the user to Discord's OAuth2 authorization page.
Requests the 'identify' and 'connections' scopes so we can retrieve
the user's Discord username, ID, and linked gaming accounts.
Returns:
Response: Redirect to Discord authorization URL.
"""
purpose = 'profile' if current_user.is_authenticated else 'registration'
session[DISCORD_PURPOSE_KEY] = purpose
return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register'
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
# raise inside the query builder rather than report a configuration error.
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
flash(_('Discord OAuth2 is not configured.'), 'danger')
return redirect(url_for(return_endpoint))
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
# attacker could have the victim's browser consume an authorization code
# obtained for the attacker's own Discord account, silently binding that
# identity to the victim's registration form.
state = secrets.token_urlsafe(32)
session[DISCORD_STATE_KEY] = state
params = {
'client_id': DISCORD_CLIENT_ID,
'redirect_uri': DISCORD_REDIRECT_URI,
'response_type': 'code',
'scope': 'identify connections',
'state': state,
}
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{urlencode(params)}'
return redirect(auth_url)
@auth_bp.route('/discord/callback')
def discord_callback():
"""Handle the OAuth2 callback from Discord.
Exchanges the authorization code for an access token, then fetches the
user's profile. During registration, connected game accounts are also
loaded into server-side draft state. For a signed-in profile relink, the
verified identity is written directly without passing through a form.
Returns:
Response: Redirect to the registration form or profile editor.
"""
# The state is consumed whatever happens next: a token is single-use, and
# leaving it in the session would allow a replay.
purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration')
if purpose == 'profile' and current_user.is_authenticated:
return_endpoint = 'users.edit_profile'
elif purpose == 'profile':
return_endpoint = 'auth.login'
else:
return_endpoint = 'auth.register'
expected_state = session.pop(DISCORD_STATE_KEY, None)
received_state = request.args.get('state', '')
if not expected_state or not secrets.compare_digest(expected_state, received_state):
flash(
_(
'Discord authorization could not be verified. '
'Please start the connection again from this page.'
),
'danger',
)
return redirect(url_for(return_endpoint))
code = request.args.get('code')
if not code:
flash(_('Discord authorization failed. No code received.'), 'danger')
return redirect(url_for(return_endpoint))
# Exchange the authorization code for an access token
token_data = {
'client_id': DISCORD_CLIENT_ID,
'client_secret': DISCORD_CLIENT_SECRET,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': DISCORD_REDIRECT_URI,
}
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
try:
token_response = requests.post(
f'{DISCORD_API_BASE}/oauth2/token',
data=token_data,
headers=headers,
timeout=10,
)
token_response.raise_for_status()
token_json = token_response.json()
access_token = token_json.get('access_token')
except requests.RequestException:
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
return redirect(url_for(return_endpoint))
if not access_token:
flash(_('Failed to obtain Discord access token.'), 'danger')
return redirect(url_for(return_endpoint))
auth_headers = {'Authorization': f'Bearer {access_token}'}
# Fetch the user's Discord profile
try:
user_response = requests.get(
f'{DISCORD_API_BASE}/users/@me',
headers=auth_headers,
timeout=10,
)
user_response.raise_for_status()
user_data = user_response.json()
except requests.RequestException:
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for(return_endpoint))
discord_user_id = user_data.get('id')
try:
if not discord_user_id:
raise ValidationError('missing Discord user id')
discord_user_id = str(discord_user_id)
validate_discord_user_id(discord_user_id)
except ValidationError:
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for(return_endpoint))
if purpose == 'profile':
# If the session expired while Discord was open, do not turn a profile
# relink into registration state for an anonymous browser.
if not current_user.is_authenticated:
flash(_('Please log in to connect your Discord account.'), 'danger')
return redirect(url_for('auth.login'))
clash = User.query.filter(
User.discord_user_id == discord_user_id,
User.id != current_user.id,
).first()
if clash:
flash(_('This Discord account is already linked to another account.'), 'danger')
return redirect(url_for('users.edit_profile'))
current_user.discord_user_id = discord_user_id
current_user.discord_username = user_data.get('username') or None
db.session.commit()
log_auth_event(
'account.discord_linked',
username=current_user.username,
user_id=current_user.id,
)
flash(_('Discord account connected!'), 'success')
return redirect(url_for('users.edit_profile'))
# Fetch the user's connected gaming accounts
connections = []
try:
conn_response = requests.get(
f'{DISCORD_API_BASE}/users/@me/connections',
headers=auth_headers,
timeout=10,
)
conn_response.raise_for_status()
connections = conn_response.json()
except requests.RequestException:
# Non-critical: we can still proceed without connections
pass
# Build gamertag suggestions from Discord connections
gamertag_suggestions = {}
for conn in connections:
platform = conn.get('type', '')
name = conn.get('name', '').strip()
if not name or platform not in DISCORD_PLATFORM_TO_GAMES:
continue
for game in DISCORD_PLATFORM_TO_GAMES[platform]:
# Only set if not already set (first connection wins)
if game not in gamertag_suggestions:
gamertag_suggestions[game] = name
# Build a list of games to auto-select (unambiguous platform mappings)
auto_select_games = []
for conn in connections:
platform = conn.get('type', '')
if platform in ('steam', 'battlenet', 'epicgames'):
for game in DISCORD_PLATFORM_TO_GAMES[platform]:
if game not in auto_select_games:
auto_select_games.append(game)
# Store in session for the registration form to use
session['discord_oauth'] = {
'id': discord_user_id,
'username': user_data.get('username'),
'avatar': user_data.get('avatar'),
'gamertag_suggestions': gamertag_suggestions,
'auto_select_games': auto_select_games,
}
flash(_('Discord account connected! Your profile has been pre-filled.'), 'success')
return redirect(url_for('auth.register'))
@auth_bp.route('/logout', methods=['POST'])
@login_required
def logout():
"""Log out the current user and clear the session.
POST, not GET: a GET route is not covered by CSRF protection, so any
page on the internet could sign a user out with an <img> tag pointing
here. A nuisance rather than a compromise, but it costs one form to
close (SEC-019).
Clears the user session and regenerates session ID to prevent
session fixation/replay after logout.
Returns:
Response: Redirect to login page with logout message.
"""
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
logout_user()
# Same reasoning as at login: the language is a display preference, not
# session state belonging to the account being signed out.
_locale = session.get(LOCALE_SESSION_KEY)
session.clear()
if _locale:
session[LOCALE_SESSION_KEY] = _locale
flash(_('You have been logged out.'), 'info')
return redirect(url_for('auth.login'))
+230
View File
@@ -0,0 +1,230 @@
"""Evaluation routes for assessing player performance during tryouts.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
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 marshmallow import ValidationError
from sqlalchemy import func
from sqlalchemy.orm import aliased
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
GAME_POSITIONS,
Admin,
Evaluation,
Player,
Tryout,
TryoutRegistration,
User,
)
from app.pagination import paginate
from app.validators import EvaluationSchema
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
@evaluations_bp.route('')
@login_required
def list_evaluations():
"""List all evaluations accessible to the current user."""
user = current_user
if isinstance(user, Player):
flash(_('You do not have permission to view evaluations.'), 'danger')
return redirect(url_for('main.dashboard'))
sort_column = request.args.get('sort', 'created_at')
sort_order = request.args.get('order', 'desc')
if sort_order not in ('asc', 'desc'):
sort_order = 'desc'
player_alias = aliased(User, name='eval_player')
evaluator_alias = aliased(User, name='eval_evaluator')
sort_map = {
'tryout': Tryout.title,
'player': player_alias.username,
'evaluator': evaluator_alias.username,
'mecanics_score': Evaluation.mecanics_score,
'cohesion_score': Evaluation.cohesion_score,
'communication_score': Evaluation.communication_score,
'gamesense_score': Evaluation.gamesense_score,
'versatility_score': Evaluation.versatility_score,
'discipline_score': Evaluation.discipline_score,
'analysis_score': Evaluation.analysis_score,
'sport_ethics_score': Evaluation.sport_ethics_score,
'mental_score': Evaluation.mental_score,
'overall_score': Evaluation.overall_score,
'position_recommendation': Evaluation.position_recommendation,
'created_at': Evaluation.created_at,
}
sort_expr = sort_map.get(sort_column, Evaluation.created_at)
if sort_order == 'asc':
sort_expr = sort_expr.asc()
else:
sort_expr = sort_expr.desc()
if isinstance(user, Admin):
evaluations_page = paginate(
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.order_by(sort_expr, Evaluation.id)
)
avg_scores = (
db.session.query(
Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'),
func.avg(Evaluation.overall_score).label('avg_score'),
)
.group_by(Evaluation.player_id)
.all()
)
player_scores = {}
for row in avg_scores:
p = User.query.get(row.player_id)
if p:
player_scores[p.id] = {
'player': p,
'count': row.eval_count,
'avg': round(row.avg_score, 1) if row.avg_score else 0,
}
else:
# Everyone still here evaluates: players were redirected above, and
# can_evaluate() is true for the four remaining roles. The former
# `else` branch listed evaluations *received* — a player's view,
# unreachable from this point (ARCH-007).
evaluations_page = paginate(
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.filter(Evaluation.evaluator_id == user.id)
.order_by(sort_expr, Evaluation.id)
)
player_scores = {}
return render_template(
'pages/evaluations.html',
evaluations=evaluations_page.items,
pagination=evaluations_page,
player_scores=player_scores,
sort_column=sort_column,
sort_order=sort_order,
)
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
@login_required
def evaluate_player(tryout_id, player_id):
"""Evaluate a specific player in a tryout."""
if not current_user.can_evaluate():
flash(_('You do not have permission to evaluate players.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id,
player_id=player_id,
).first()
is not None
)
if not is_registered:
flash(_('Player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(player_id)
if not isinstance(player, Player):
flash(_('Can only evaluate players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing_eval = Evaluation.query.filter_by(
tryout_id=tryout_id,
player_id=player_id,
evaluator_id=current_user.id,
).first()
def render_evaluation_form():
evaluators = None
if isinstance(current_user, Admin):
all_evaluations = Evaluation.query.filter_by(
tryout_id=tryout_id,
player_id=player_id,
).all()
evaluators = [
{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations
]
return render_template(
'pages/evaluate_player.html',
tryout=tryout,
player=player,
existing_eval=existing_eval,
evaluators=evaluators,
game_positions=GAME_POSITIONS,
)
if request.method == 'POST':
try:
data = EvaluationSchema().load(form_payload(list_fields=(), optional_blank=()))
except ValidationError as err:
flash_validation_errors(err)
return render_evaluation_form()
evaluation = existing_eval
if evaluation is None:
evaluation = Evaluation(
tryout_id=tryout_id,
player_id=player_id,
evaluator_id=current_user.id,
)
db.session.add(evaluation)
flash(_('Evaluation submitted successfully!'), 'success')
else:
flash(_('Evaluation updated!'), 'success')
evaluation.apply_scores(data)
evaluation.comments = data['comments']
evaluation.position_recommendation = data['position_recommendation']
db.session.commit()
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_evaluation_form()
@evaluations_bp.route('/<int:tryout_id>/players')
@login_required
def players_to_evaluate(tryout_id):
"""List players that need evaluation in a specific tryout."""
if not current_user.can_evaluate():
flash(_('Permission denied.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
players = []
for reg in registrations:
p = User.query.get(reg.player_id)
if p and isinstance(p, Player):
existing = Evaluation.query.filter_by(
tryout_id=tryout_id,
player_id=p.id,
evaluator_id=current_user.id,
).first()
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
+242
View File
@@ -0,0 +1,242 @@
"""Main dashboard routes for the Team Tryouts application.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
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,
Coach,
Evaluation,
Manager,
Match,
MatchParticipant,
Player,
Scout,
TeamMember,
Tryout,
TryoutRegistration,
User,
)
from app.permissions import coach_tryout_ids
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
"""Redirect root URL to login page."""
return redirect(url_for('auth.login'))
@main_bp.route('/lang/<locale>')
def set_language(locale):
"""Switch the interface language and return where the user came from.
Available to anonymous visitors too: the login page has to be readable
before anyone can sign in.
A GET link rather than a form: the only thing a forged request could
achieve is changing the visitor's own display language, which carries
no consequence worth a token. The redirect target is still validated —
an unchecked `Referer` would make this an open redirect.
"""
from app.i18n import set_locale
from app.routes.auth import is_safe_url
if not set_locale(locale):
flash(_('That language is not available.'), 'warning')
target = request.referrer
if target and is_safe_url(target):
return redirect(target)
return redirect(
url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login')
)
@main_bp.route('/dashboard')
@login_required
def dashboard():
"""Render the main dashboard with role-specific statistics.
Each User subclass provides its own stats view.
"""
user = current_user
stats = {}
if isinstance(user, Admin):
stats['total_users'] = User.query.count()
stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_tryouts'] = Tryout.query.count()
stats['total_evaluations'] = Evaluation.query.count()
stats['active_tryouts'] = Tryout.query.filter_by(status='in_progress').count()
stats['completed_tryouts'] = Tryout.query.filter_by(status='completed').count()
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all()
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
today = date.today()
stats['upcoming_matches'] = (
Match.query.filter(
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
)
elif isinstance(user, Manager):
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
stats['active_tryouts'] = Tryout.query.filter_by(
created_by=user.id, status='in_progress'
).count()
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
stats['my_tryouts'] = (
Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
)
today = date.today()
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
stats['upcoming_matches'] = (
Match.query.filter(
Match.tryout_id.in_(manager_tryout_ids),
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
if manager_tryout_ids
else []
)
elif isinstance(user, Coach):
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
# A count, computed as a count. This used to load every registration
# row in the club and every evaluation this coach had written, build
# two Python sets and subtract them — two full table reads to produce
# one integer (PERF-004).
already_evaluated = (
db.session.query(Evaluation.player_id)
.filter(
Evaluation.evaluator_id == user.id,
Evaluation.player_id == TryoutRegistration.player_id,
)
.exists()
)
stats['pending_evaluations'] = (
db.session.query(func.count(func.distinct(TryoutRegistration.player_id)))
.filter(
TryoutRegistration.status.in_(['registered', 'attended']),
~already_evaluated,
)
.scalar()
)
stats['my_recent_evaluations'] = (
Evaluation.query.filter_by(evaluator_id=user.id)
.order_by(Evaluation.created_at.desc())
.limit(10)
.all()
)
today = date.today()
# Was: the first team matching the legacy coach_id column, and only
# the tryouts targeting it. A coach attached by the many-to-many
# relationship, or coaching a second team, saw no upcoming match.
tryout_ids = coach_tryout_ids(user)
stats['upcoming_matches'] = (
Match.query.filter(
Match.tryout_id.in_(tryout_ids),
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
if tryout_ids
else []
)
elif isinstance(user, Player):
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
stats['my_registrations'] = (
TryoutRegistration.query.filter_by(player_id=user.id)
.order_by(TryoutRegistration.registered_at.desc())
.limit(5)
.all()
)
today = date.today()
next_matches = []
all_registrations = TryoutRegistration.query.filter_by(player_id=user.id).all()
registered_tryout_ids = [r.tryout_id for r in all_registrations]
player_participant_matches = MatchParticipant.query.filter_by(player_id=user.id).all()
player_match_ids = [p.match_id for p in player_participant_matches]
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
player_team_ids = [tm.team_id for tm in player_team_memberships]
upcoming_matches = (
Match.query.filter(
Match.tryout_id.in_(registered_tryout_ids),
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.all()
)
for match in upcoming_matches:
is_participant = False
team = None
if match.match_type == 'team_vs_team':
if match.team1_id in player_team_ids:
is_participant = True
team = next(
(tm for tm in player_team_memberships if tm.team_id == match.team1_id), None
)
elif match.team2_id in player_team_ids:
is_participant = True
team = next(
(tm for tm in player_team_memberships if tm.team_id == match.team2_id), None
)
else:
if match.id in player_match_ids:
is_participant = True
if is_participant:
next_matches.append(
{
'tryout': match.tryout,
'match': match,
'team': team.team if team else None,
}
)
stats['next_matches'] = next_matches
elif isinstance(user, Scout):
stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = (
db.session.query(
Evaluation.player_id,
func.avg(Evaluation.overall_score).label('avg_score'),
)
.group_by(Evaluation.player_id)
.order_by(func.avg(Evaluation.overall_score).desc())
.limit(5)
.all()
)
stats['top_players'] = []
for row in stats['avg_scores']:
p = User.query.get(row.player_id)
if p:
stats['top_players'].append((p, round(row.avg_score, 1)))
return render_template('pages/dashboard.html', user=user, stats=stats)
+678
View File
@@ -0,0 +1,678 @@
"""Match scheduling routes for managing scrimmages and matches within tryouts.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
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.api import json_endpoint
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Admin,
Coach,
Manager,
Match,
MatchParticipant,
OneOnOneRequest,
PersonalNote,
Player,
PlayerDisponibility,
Scout,
Team,
TeamMember,
Tryout,
TryoutRegistration,
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))
def get_visible_tryouts_for_user():
"""Get tryouts that the current user can see based on their role.
Delegates to the polymorphic User subclass.
"""
return current_user.get_visible_tryouts()
@matches_bp.route('/calendar')
@login_required
def calendar():
"""Render the calendar view."""
return render_template('pages/calendar.html')
def calendar_window(args):
"""The date range FullCalendar is asking about, if it said.
A URL event source appends `start` and `end` automatically, in ISO 8601
with an offset (`2026-08-01T00:00:00-04:00`). Only the date part is
needed here, and a value that does not parse is treated as absent
rather than as an error: a calendar that shows too much is a
performance problem, one that 400s is a broken page.
Args:
args: request.args.
Returns:
tuple[date | None, date | None]: Inclusive bounds.
"""
def _parse(value):
if not value:
return None
try:
return datetime.strptime(value[:10], '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
return _parse(args.get('start')), _parse(args.get('end'))
@matches_bp.route('/api/events')
@json_endpoint
@login_required
def api_events():
"""Calendar events for FullCalendar.
Bounded and batched (PERF-002). This used to walk `tryout.matches` for
every visible tryout every tryout the club has ever run, for a
president and then issue one MatchParticipant query per match to find
out whether the viewer was in it. The calendar's cost grew with the
whole history, on every navigation.
"""
events = []
tryouts = get_visible_tryouts_for_user()
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
if tryouts_by_id:
window_start, window_end = calendar_window(request.args)
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
if window_start:
query = query.filter(Match.date >= window_start)
if window_end:
query = query.filter(Match.date <= window_end)
matches = query.all()
# Participants for every match in the window, in one query rather
# than one per match. `participants` is a dynamic relationship, so
# eager loading options do not apply to it.
match_ids = [match.id for match in matches]
participants_by_match = {}
mine_by_match = {}
if match_ids:
rows = (
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids))
.options(joinedload(MatchParticipant.player))
.all()
)
for row in rows:
participants_by_match.setdefault(row.match_id, []).append(row)
if row.player_id == current_user.id:
mine_by_match[row.match_id] = row
for match in matches:
tryout = tryouts_by_id[match.tryout_id]
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
# 'description' used to be participants_str + '<br>' + description.
# Building presentation markup inside a JSON field is what carried
# the stored XSS: the browser dropped it straight into innerHTML,
# and player usernames travelled through it unescaped. The two
# values are already separate keys, so the concatenation also made
# the modal show the participants twice.
participants_str = ''
if match.match_type == 'team_vs_team':
teams = []
if match.team1:
teams.append(match.team1.name)
if match.team2:
teams.append(match.team2.name)
participants_str = ' vs '.join(teams)
else:
player_names = [
p.player.username if p.player else 'Unknown Player'
for p in participants_by_match.get(match.id, [])
]
participants_str = ', '.join(player_names) if player_names else 'No players'
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
user_participant = mine_by_match.get(match.id)
events.append(
{
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
'description': match.description or '',
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'start_time': start_time_str,
'end_time': end_time_str,
'participants': participants_str,
'user_participant_id': user_participant.id if user_participant else None,
'user_attendance_confirmed': user_participant.attendance_confirmed
if user_participant
else False,
},
}
)
# Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player):
one_on_ones = OneOnOneRequest.query.filter_by(
player_id=current_user.id, status='approved'
).all()
elif isinstance(current_user, Coach):
one_on_ones = OneOnOneRequest.query.filter_by(
coach_id=current_user.id, status='approved'
).all()
else:
one_on_ones = []
for ooo in one_on_ones:
events.append(
{
'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'date': ooo.date.strftime('%Y-%m-%d'),
'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': {
'location': 'Discord / Voice Chat',
'status': 'approved',
'description': ooo.points or 'One on One session',
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
},
}
)
return jsonify(events)
@matches_bp.route('/api/events/<int:tryout_id>')
@json_endpoint
@login_required
def api_events_for_tryout(tryout_id):
"""API endpoint returning calendar events for a specific tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
can_view = current_user.can_manage_this_tryout(tryout)
is_registered = False
player_in_match = False
if isinstance(current_user, Player):
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id,
player_id=current_user.id,
).first()
is not None
)
player_matches = (
Match.query.join(MatchParticipant)
.filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.all()
)
player_in_match = len(player_matches) > 0
if not can_view and not is_registered and not player_in_match:
return jsonify([])
events = []
for match in tryout.matches:
match_color = (
'#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
)
participants_str = ''
if match.match_type == 'team_vs_team':
teams = []
if match.team1:
teams.append(match.team1.name)
if match.team2:
teams.append(match.team2.name)
participants_str = f"{' vs '.join(teams)}"
elif match.match_type == 'player_vs_player':
team1_players = [
p.player.username
for p in match.participants.filter_by(team_side=1).all()
if p.player
]
team2_players = [
p.player.username
for p in match.participants.filter_by(team_side=2).all()
if p.player
]
if team1_players and team2_players:
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
else:
participants_str = 'TBD vs TBD'
else:
player_names = [p.player.username for p in match.participants.all() if p.player]
participants_str = ', '.join(player_names) if player_names else 'No players'
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
events.append(
{
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'participants': participants_str,
'start_time': start_time_str,
'end_time': end_time_str,
},
}
)
return jsonify(events)
@matches_bp.route('/create/<int:tryout_id>', methods=['GET', 'POST'])
@login_required
def create_match(tryout_id):
"""Create a new match / scrimmage within a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
teams = Team.query.filter_by(tryout_id=tryout_id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
all_players = [
User.query.get(r.player_id) for r in registrations if User.query.get(r.player_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':
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:
data = MatchSchema().load(payload)
except ValidationError as err:
flash_validation_errors(err)
return rerender()
match = Match(
tryout_id=tryout_id,
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()
if data['match_type'] == 'team_vs_team':
match.team1_id = data['team1_id']
match.team2_id = data['team2_id']
notified_player_ids, notified_participant_ids = create_participants(match, data)
db.session.commit()
notify_participants(
title=match.title,
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,
)
flash(_('Match scheduled successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return rerender()
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_match(match_id):
"""Edit an existing match."""
match = Match.query.get_or_404(match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('matches.calendar'))
if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
teams = Team.query.filter_by(tryout_id=tryout.id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
all_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
current_player_ids = [p.player_id for p in match.participants.all()]
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 <script> block so a rejected
edit died in `tojson` on an Undefined, turning a validation message
into a 500.
"""
participants_map = {
p.player_id: {
'participant_id': p.id,
'attendance_confirmed': p.attendance_confirmed,
'team_side': p.team_side,
}
for p in match.participants.all()
}
return render_template(
'pages/match_form.html',
match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
team1_player_ids=team1_player_ids,
team2_player_ids=team2_player_ids,
participants_map=participants_map,
)
if request.method == 'POST':
try:
data = MatchEditSchema().load(match_form_payload())
except ValidationError as err:
flash_validation_errors(err)
return rerender()
# Assigned only once the whole form has been accepted. Assigning as
# each field was read meant a form rejected halfway had already
# changed the record in the session.
match.title = data['title']
match.description = data['description']
match.date = data['date']
match.start_time = data['start_time']
match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
match.location = data['location']
match.status = data['status']
notified_player_ids = []
notified_participant_ids = []
if match.match_type == 'team_vs_team':
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
if teams_changed:
MatchParticipant.query.filter_by(match_id=match.id).delete()
match.team1_id = data['team1_id']
match.team2_id = data['team2_id']
notified_player_ids, notified_participant_ids = create_participants(match, data)
else:
# Same teams: the roster stands, but everyone is told again,
# because the date or the time may have moved.
for team_id in (match.team1_id, match.team2_id):
if team_id:
notified_player_ids.extend(
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
)
else:
MatchParticipant.query.filter_by(match_id=match.id).delete()
notified_player_ids, notified_participant_ids = create_participants(match, data)
db.session.commit()
notify_participants(
title=match.title,
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,
)
flash(_('Match updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return rerender()
@matches_bp.route('/api/manageable-tryouts')
@json_endpoint
@login_required
def api_manageable_tryouts():
"""API endpoint returning tryouts the current user can manage."""
if not can_schedule_match():
return jsonify([])
tryouts = get_visible_tryouts_for_user()
manageable = []
for t in tryouts:
if current_user.can_manage_this_tryout(t):
manageable.append(
{
'id': t.id,
'title': t.title,
'date': t.date.strftime('%Y-%m-%d'),
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
}
)
return jsonify(manageable)
@matches_bp.route('/<int:match_id>/delete', methods=['POST'])
@login_required
def delete_match(match_id):
"""Delete a match."""
match = Match.query.get_or_404(match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('matches.calendar'))
if tryout.is_ended:
flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
# Notes outlive the match they were taken during: a coach's observation
# keeps its value, and deleting it here would destroy unrelated content.
# Only the context link is dropped. Participants go through the
# relationship's delete-orphan cascade.
PersonalNote.query.filter_by(match_id=match_id).update(
{'match_id': None}, synchronize_session=False
)
db.session.delete(match)
db.session.commit()
flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
def get_players_available_at_time(date_str, time_str):
"""Player IDs whose weekly availability covers this date and time.
Two queries, whatever the size of the club. This used to load every
active player and then run one PlayerDisponibility query per player, on
an unindexed column sixty players meant sixty-one round trips to
answer a question the database can answer in one (PERF-003).
Args:
date_str: 'YYYY-MM-DD'.
time_str: 'HH:MM'.
Returns:
list[int]: Player IDs, empty when the input does not parse.
"""
try:
parsed_date = datetime.strptime(date_str, '%Y-%m-%d')
time_obj = datetime.strptime(time_str, '%H:%M').time()
except (ValueError, TypeError):
return []
day_of_week = parsed_date.weekday()
active_player_ids = {
row.id
for row in User.query.with_entities(User.id)
.filter_by(role='player', is_active_account=True)
.all()
}
if not active_player_ids:
return []
# The comparison stays in Python: start_time and end_time are stored as
# time columns, and comparing them in SQL across three backends is not
# worth the portability risk for a single day's rows.
minutes = time_obj.hour * 60 + time_obj.minute
available = []
seen = set()
for disp in PlayerDisponibility.query.filter_by(day_of_week=day_of_week).all():
if disp.player_id in seen or disp.player_id not in active_player_ids:
continue
start = disp.start_time.hour * 60 + disp.start_time.minute
end = disp.end_time.hour * 60 + disp.end_time.minute
if start <= minutes < end:
available.append(disp.player_id)
seen.add(disp.player_id)
return available
@matches_bp.route('/api/available_players/<date>/<time>')
@json_endpoint
@login_required
def api_available_players(date, time):
"""API endpoint to get players available at a specific date/time slot."""
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
return jsonify({'error': 'Unauthorized'}), 403
player_ids = get_players_available_at_time(date, time)
return jsonify({'available_player_ids': player_ids})
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@json_endpoint
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle attendance_confirmed for a match participant."""
match = Match.query.get_or_404(match_id)
tryout = match.tryout
participant = MatchParticipant.query.get_or_404(participant_id)
if participant.match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
is_self = participant.player_id == current_user.id
if not is_self and not current_user.can_manage_this_tryout(tryout):
return jsonify({'error': 'Unauthorized'}), 403
participant.attendance_confirmed = not participant.attendance_confirmed
db.session.commit()
return jsonify(
{
'participant_id': participant.id,
'attendance_confirmed': participant.attendance_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
}
)
+315
View File
@@ -0,0 +1,315 @@
"""Team match management routes for regular season matches.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
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 marshmallow import ValidationError
from app.api import json_endpoint
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Admin,
Coach,
Manager,
OrgTeam,
Player,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
)
from app.pagination import paginate
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants
from app.validators import TeamMatchSchema
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
def can_manage_team_match(team):
"""Whether the current user can manage matches for this team.
Same rule as administering the team itself, so it is the same call.
This function used to restate it, and the restatement drifted.
"""
return can_manage_org_team(current_user, team)
@team_matches_bp.route('')
@login_required
def list_matches():
"""List all team matches visible to the current user."""
filter_team_id = request.args.get('team_id', type=int)
# A manager administers every team, so the listing shows them all;
# visible_org_teams() only reports the teams they are attached to.
if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
matches_query = TeamMatch.query
elif isinstance(current_user, (Coach, Player)):
teams = visible_org_teams(current_user)
team_ids = [t.id for t in teams]
matches_query = (
TeamMatch.query.filter(
TeamMatch.org_team_id.in_(team_ids),
)
if team_ids
else TeamMatch.query.filter(TeamMatch.id == -1)
)
else:
teams = []
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
if filter_team_id:
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
# Pagination also bounds the per-match participant loop below, which is
# the N+1 the constat pointed at (MNT-10 combined with MNT-14).
matches_page = paginate(matches_query.order_by(TeamMatch.date.desc(), TeamMatch.id))
matches = matches_page.items
match_data = []
for tm in matches:
confirmed, total = tm.get_confirmed_count()
participants = []
for p in tm.participants.all():
participants.append(
{
'id': p.id,
'player': p.player,
'is_confirmed': p.is_confirmed,
}
)
match_data.append(
{
'match': tm,
'participants': participants,
'confirmed_count': confirmed,
'total_count': total,
}
)
return render_template(
'pages/team_matches.html',
teams=teams,
match_data=match_data,
pagination=matches_page,
now=datetime.utcnow(),
)
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
@login_required
def create_match(team_id):
"""Create a new regular-season team match."""
team = OrgTeam.query.get_or_404(team_id)
if not can_manage_team_match(team):
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
prefill_date = request.args.get('date', '')
is_practice = request.args.get('type') == 'practice'
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
if is_practice and request.method == 'GET':
class TryoutProxy:
def __init__(self, team_obj):
self.id = 0
self.title = team_obj.name
self.date = ''
self.game = ''
self.target_org_team = team_obj
proxy_tryout = TryoutProxy(team)
all_players = [tp.player for tp in team_players if tp.player]
return render_template(
'pages/match_form.html',
tryout=proxy_tryout,
teams=[],
all_players=all_players,
prefill_date=prefill_date,
is_practice=True,
team_id=team_id,
team=team,
)
if request.method == 'POST':
payload = form_payload(list_fields=(), optional_blank=())
# A practice has no opponent, whatever the form sent.
payload.setdefault('title', default_title)
if is_practice:
payload.pop('opponent', None)
try:
data = TeamMatchSchema().load(payload)
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/team_match_form.html',
team=team,
team_players=team_players,
prefill_date=prefill_date,
is_practice=is_practice,
)
start_time = data['start_time']
end_time = data['end_time'] or default_end_time(data['date'], start_time)
team_match = TeamMatch(
org_team_id=team_id,
title=data['title'],
description=data['description'],
opponent=data['opponent'],
date=data['date'],
start_time=start_time,
end_time=end_time,
location=data['location'],
created_by=current_user.id,
)
db.session.add(team_match)
db.session.flush()
notified_participant_ids = []
for tp in team_players:
participant = TeamMatchParticipant(
team_match_id=team_match.id,
player_id=tp.player_id,
)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
db.session.commit()
notify_participants(
title=team_match.title,
date=team_match.date,
start_time=start_time,
end_time=end_time,
participants=zip_participants(
[tp.player_id for tp in team_players], notified_participant_ids
),
fallback_id=team_match.id,
)
flash(
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
)
return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
@team_matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_match(match_id):
"""Edit an existing team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team = team_match.org_team
if not can_manage_team_match(team):
flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
if request.method == 'POST':
# The three date and time fields used to be checked one at a time,
# each flashing and redirecting on its own: a form with two mistakes
# took two round trips to be told about both. One schema now, every
# problem reported at once and in place.
#
# Known limit: the re-render reads the stored record, so what was
# typed is not echoed back. Repopulating the form from the
# submission is a separate change to the template.
try:
data = TeamMatchSchema().load(form_payload(list_fields=(), optional_blank=()))
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
)
team_match.title = data['title']
team_match.description = data['description']
team_match.opponent = data['opponent']
team_match.date = data['date']
team_match.start_time = data['start_time']
team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
team_match.location = data['location']
team_match.status = data['status']
db.session.commit()
flash(_('Match updated successfully!'), 'success')
return redirect(url_for('team_matches.list_matches'))
return render_template(
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
)
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
@login_required
def delete_match(match_id):
"""Delete a team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team = team_match.org_team
if not can_manage_team_match(team):
flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
db.session.delete(team_match)
db.session.commit()
flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('team_matches.list_matches'))
@team_matches_bp.route('/api/manageable-teams')
@json_endpoint
@login_required
def api_manageable_teams():
"""API endpoint returning teams the current user can schedule matches for."""
if not current_user.can_schedule_matches():
return jsonify([])
if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, Coach):
teams = coach_org_teams(current_user)
else:
return jsonify([])
return jsonify([{'id': t.id, 'name': t.name} for t in teams])
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@json_endpoint
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle is_confirmed for a team match participant."""
team_match = TeamMatch.query.get_or_404(match_id)
team = team_match.org_team
participant = TeamMatchParticipant.query.get_or_404(participant_id)
if participant.team_match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
can_toggle = can_manage_team_match(team) or participant.player_id == current_user.id
if not can_toggle:
return jsonify({'error': 'Unauthorized'}), 403
participant.is_confirmed = not participant.is_confirmed
db.session.commit()
return jsonify(
{
'participant_id': participant.id,
'is_confirmed': participant.is_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
}
)
+612
View File
@@ -0,0 +1,612 @@
"""Organization team management routes.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
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 marshmallow import ValidationError
from app.api import json_endpoint
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Admin,
Coach,
Contract,
Manager,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Player,
TeamMatch,
TeamNote,
TeamPlayer,
Tryout,
User,
)
from app.permissions import visible_org_teams
from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@teams_bp.route('')
@login_required
def list_teams():
"""List all organization teams visible to the current user."""
can_manage = current_user.can_manage_teams()
if isinstance(current_user, Player):
flash(_('Use My Team(s) to view your teams.'), 'info')
return redirect(url_for('teams.my_teams'))
if not isinstance(current_user, (Admin, Coach, Manager)):
flash(_('You do not have permission to view teams.'), 'danger')
return redirect(url_for('main.dashboard'))
teams = visible_org_teams(current_user)
coaches = (
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
)
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
)
# is_active_account, like the two queries above it. Without it the "add
# player" select offered accounts that had been deactivated, and
# add_player accepted them.
all_players = (
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
)
return render_template(
'pages/teams.html',
teams=teams,
coaches=coaches,
managers=managers,
all_players=all_players,
can_manage=can_manage,
)
@teams_bp.route('/my-teams')
@login_required
def my_teams():
"""View the player's own teams with upcoming matches."""
if not isinstance(current_user, Player):
flash(_('This page is for players.'), 'info')
return redirect(url_for('teams.list_teams'))
from app.models import TeamMatch, TeamMatchParticipant
player_teams = current_user.get_org_teams()
now = datetime.utcnow()
team_data = []
for org_team in player_teams:
matches = (
TeamMatch.query.filter(
TeamMatch.org_team_id == org_team.id,
TeamMatch.status == 'scheduled',
)
.order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc())
.all()
)
matches_data = []
for tm in matches:
confirmed, total = tm.get_confirmed_count()
participant = TeamMatchParticipant.query.filter_by(
team_match_id=tm.id,
player_id=current_user.id,
).first()
matches_data.append(
{
'match': tm,
'participant_id': participant.id if participant else None,
'is_confirmed': participant.is_confirmed if participant else False,
'confirmed_count': confirmed,
'total_count': total,
}
)
team_data.append(
{
'team': org_team,
'matches': matches_data,
'coaches': org_team.get_coaches(),
'managers': org_team.get_managers(),
}
)
return render_template('pages/my_teams.html', team_data=team_data, now=now)
def _posted(schema):
"""Load a form through `schema`, or None when it will not load.
The five assignment routes below each answer a bad field with their own
flash and a redirect to the same page, so a shared "it did not validate"
return is enough; the field-level message is flashed on the way out.
"""
try:
return schema.load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return None
def _assignable(user, expected_class):
"""Whether this account may be given a role on a team.
Deactivated accounts were offered by the selects and accepted by the
routes. `is_active_account` is what stops someone logging in a person
who has left the club so putting them on a roster contradicts the one
control that says they are gone. The listings filtered it for coaches and
managers and not for players, two lines apart, which is how it went
unnoticed.
"""
return isinstance(user, expected_class) and bool(user.is_active_account)
def _staff_member(user_id, expected_class):
"""The user behind an id, only if they may hold the role being assigned.
Returns None for a missing id, an unknown id, an account of the wrong
role, or a deactivated one. The role check is the point (SEC-16): the id
comes from a `<select>` the browser rendered, so it is a value the client
chooses, and nothing checked it in two of the three places that used it.
A forged submission could therefore list a player among a team's coaches
the same defect wave G fixed in `tryouts.py`, left standing here.
Defers to `_assignable` rather than repeating `isinstance`: two functions
in one file answering "may this account take this role" differently is
the shape of every defect this module has had.
Args:
user_id: Already an int or None, thanks to the schema.
expected_class: Coach or Manager.
Returns:
User | None: The account, when it may take the role.
"""
if not user_id:
return None
user = db.session.get(User, user_id)
return user if user and _assignable(user, expected_class) else None
@teams_bp.route('/create', methods=['POST'])
@login_required
def create_team():
"""Create a new organization team."""
if not current_user.can_manage_teams():
flash(_('You do not have permission to create teams.'), 'danger')
return redirect(url_for('teams.list_teams'))
try:
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
name = data['name']
if OrgTeam.query.filter_by(name=name).first():
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams'))
coach = _staff_member(data['coach_id'], Coach)
manager = _staff_member(data['manager_id'], Manager)
team = OrgTeam(
name=name,
coach_id=coach.id if coach else None,
manager_id=manager.id if manager else None,
created_by=current_user.id,
)
db.session.add(team)
db.session.flush()
if coach:
team.coaches.append(coach)
if manager:
team.managers.append(manager)
db.session.commit()
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
@login_required
def edit_team(team_id):
"""Edit an existing organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to edit this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
try:
data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
name = data['name']
if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams'))
team.name = name
if data['sync_staff'] == '1':
team.coaches = [
user for user in (_staff_member(cid, Coach) for cid in data['coach_ids']) if user
]
coach_list = team.coaches.all()
team.coach_id = coach_list[0].id if coach_list else None
team.managers = [
user for user in (_staff_member(mid, Manager) for mid in data['manager_ids']) if user
]
manager_list = team.managers.all()
team.manager_id = manager_list[0].id if manager_list else None
else:
# This branch never checked the role, while the one above did — the
# same file disagreeing with itself (SEC-16). _staff_member is the
# single answer now.
coach = _staff_member(data['coach_id'], Coach)
manager = _staff_member(data['manager_id'], Manager)
team.coach_id = coach.id if coach else None
team.manager_id = manager.id if manager else None
if coach and not team.coaches.filter_by(id=coach.id).first():
team.coaches.append(coach)
if manager and not team.managers.filter_by(id=manager.id).first():
team.managers.append(manager)
db.session.commit()
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
@login_required
def delete_team(team_id):
"""Delete an organization team.
Two checks, not one, and not the one the audit recommended (SEC-AUTHZ-006).
The constat was right about the inconsistency: this was the only team
operation guarded by the global `can_manage_teams()` while the other
nine use `can_manage_this_org_team(team)`. It was wrong about the fix.
Simply swapping to the per-object check **widens** access `Coach`
returns False for the global capability and True for its own teams, so
the swap would hand every coach the power to delete the team they coach,
along with its notes and its match history. The constat reasoned about
`Manager`, where both return True, and missed the role where they differ.
Requiring both preserves today's behaviour exactly (admins and managers
yes, coaches no) and still closes the debt the constat was about: the
day `Manager.can_manage_this_org_team` is narrowed which it should be
deletion narrows with it instead of staying the one way in.
"""
team = OrgTeam.query.get_or_404(team_id)
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
flash(_('You do not have permission to delete teams.'), 'danger')
return redirect(url_for('teams.list_teams'))
name = team.name
# One transaction. This used to commit three times, so a failure at the
# third step left the tryouts detached and the players removed without
# the team being deleted — an inconsistent state nothing could undo.
#
# TeamNote.org_team_id and TeamMatch.org_team_id are NOT NULL, and were
# not handled at all: deleting a team that had ever been used raised
# IntegrityError. Contract.team_id and OneOnOneRequest.org_team_id are
# nullable, and the rows outlive the team, so they are only detached.
# Entities that only make sense as part of the team.
TeamNote.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
for team_match in TeamMatch.query.filter_by(org_team_id=team_id).all():
db.session.delete(team_match) # participants follow by cascade
TeamPlayer.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
# Entities that survive it.
Tryout.query.filter_by(target_org_team_id=team_id).update(
{'target_org_team_id': None}, synchronize_session=False
)
Contract.query.filter_by(team_id=team_id).update({'team_id': None}, synchronize_session=False)
OneOnOneRequest.query.filter_by(org_team_id=team_id).update(
{'org_team_id': None}, synchronize_session=False
)
db.session.delete(team)
db.session.commit()
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
@login_required
def add_coach(team_id):
"""Add a coach to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if not data['coach_id']:
flash(_('Please select a coach.'), 'danger')
return redirect(url_for('teams.list_teams'))
coach = db.session.get(User, data['coach_id'])
if not coach or not _assignable(coach, Coach):
flash(_('Only coaches can be assigned as coach.'), 'danger')
return redirect(url_for('teams.list_teams'))
if team.coaches.filter_by(id=coach.id).first():
flash(
_(
'%(username)s is already a coach of %(name)s.',
username=coach.username,
name=team.name,
),
'info',
)
return redirect(url_for('teams.list_teams'))
team.coaches.append(coach)
if not team.coach_id:
team.coach_id = coach.id
db.session.commit()
flash(
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
@login_required
def add_manager(team_id):
"""Add a manager to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if not data['manager_id']:
flash(_('Please select a manager.'), 'danger')
return redirect(url_for('teams.list_teams'))
manager = db.session.get(User, data['manager_id'])
if not manager or not _assignable(manager, Manager):
flash(_('Only managers can be assigned as manager.'), 'danger')
return redirect(url_for('teams.list_teams'))
if team.managers.filter_by(id=manager.id).first():
flash(
_(
'%(username)s is already a manager of %(name)s.',
username=manager.username,
name=team.name,
),
'info',
)
return redirect(url_for('teams.list_teams'))
team.managers.append(manager)
if not team.manager_id:
team.manager_id = manager.id
db.session.commit()
flash(
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
@login_required
def remove_coach(team_id):
"""Remove a coach from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if data['coach_id']:
coach = db.session.get(User, data['coach_id'])
if coach and team.coaches.filter_by(id=coach.id).first():
team.coaches.remove(coach)
if team.coach_id == coach.id:
team.coach_id = None
else:
team.coaches = []
team.coach_id = None
db.session.commit()
flash(_('Coach removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
@login_required
def remove_manager(team_id):
"""Remove a manager from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if data['manager_id']:
manager = db.session.get(User, data['manager_id'])
if manager and team.managers.filter_by(id=manager.id).first():
team.managers.remove(manager)
if team.manager_id == manager.id:
team.manager_id = None
else:
team.managers = []
team.manager_id = None
db.session.commit()
flash(_('Manager removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
@login_required
def add_player(team_id):
"""Add a player to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
data = _posted(TeamPlayerSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if not data['player_id']:
flash(_('Please select a player.'), 'danger')
return redirect(url_for('teams.list_teams'))
status = data['status']
player = db.session.get(User, data['player_id'])
if not player or not _assignable(player, Player):
flash(_('Can only assign players to teams.'), 'danger')
return redirect(url_for('teams.list_teams'))
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
if existing:
flash(
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
'info',
)
return redirect(url_for('teams.list_teams'))
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
db.session.add(tp)
db.session.commit()
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
@login_required
def remove_player(team_id, player_id):
"""Remove a player from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
flash(
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger',
)
return redirect(url_for('teams.list_teams'))
db.session.delete(tp)
db.session.commit()
flash(
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
@json_endpoint
@login_required
def toggle_player_status(team_id, player_id):
"""Toggle a player's status between starter and substitute."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
return jsonify({'error': 'Permission denied'}), 403
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
return jsonify({'error': 'Player not found on this team'}), 404
tp.status = 'substitute' if tp.status == 'starter' else 'starter'
db.session.commit()
return jsonify(
{
'success': True,
'player_id': player_id,
'new_status': tp.status,
'player_name': tp.player.username,
}
)
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
@login_required
def add_team_note(team_id):
"""Add a team improvement note (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash(_('Team notes added successfully!'), 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
@login_required
def add_player_note(team_id, player_id):
"""Add a personal note for a player (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('teams.list_teams'))
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
flash(
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger',
)
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success')
return redirect(url_for('teams.list_teams'))
+660
View File
@@ -0,0 +1,660 @@
"""Tryout management routes for creating, viewing, and managing tryout events.
This module handles CRUD operations for tryouts and player registrations.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
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 marshmallow import ValidationError
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
ESPORT_GAMES,
GAME_POSITIONS,
Admin,
Coach,
Evaluation,
Manager,
Match,
MatchParticipant,
OrgTeam,
PersonalNote,
Player,
Scout,
Team,
TeamMember,
Tryout,
TryoutRegistration,
User,
)
from app.validators import PlayerSelectionSchema, TryoutSchema
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
def can_manage():
"""Check if current user can manage tryouts (Admin or Manager)."""
return isinstance(current_user, (Admin, Manager))
def tryout_form_payload():
"""The tryout form, shaped for marshmallow (ARCH-005)."""
return form_payload(list_fields=('coach_ids',), optional_blank=())
def coaches_from_ids(coach_ids):
"""The coach accounts behind these ids.
Filtered by role, which the previous `User.id.in_(...)` was not: the form
posts a list of ids and nothing stopped a hand-made submission from
naming a player, who then appeared as a coach of the tryout and inherited
every permission that comes with it.
"""
if not coach_ids:
return []
return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
def _users_by_id(user_ids):
"""Load these users in one query, keyed by id.
Replaces the `User.query.get()`-inside-a-loop that view_tryout used in
three separate places (PERF-001). Missing ids are simply absent from
the result, which is what a per-row get() returning None amounted to.
Args:
user_ids: Iterable of primary keys, may repeat and may be empty.
Returns:
dict[int, User]
"""
wanted = {user_id for user_id in user_ids if user_id}
if not wanted:
return {}
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
@tryouts_bp.route('')
@login_required
def list_tryouts():
"""List all tryouts visible to the current user.
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
"""
tryouts = current_user.get_visible_tryouts()
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
@tryouts_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_tryout():
"""Create a new tryout event. Requires Admin or Manager."""
if not can_manage():
flash(_('You do not have permission to create tryouts.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
)
coaches = (
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
)
def rerender():
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
if request.method == 'POST':
try:
data = TryoutSchema().load(tryout_form_payload())
except ValidationError as err:
flash_validation_errors(err)
return rerender()
tryout = Tryout(
title=data['title'],
description=data['description'],
game=data['game'],
date=data['date'],
end_date=data['end_date'],
location=data['location'],
max_players=data['max_players'],
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'])
db.session.commit()
flash(_('Tryout created successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return rerender()
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_tryout(tryout_id):
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryout.is_ended:
flash(_('This tryout has ended and can no longer be modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
)
coaches = (
User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
)
def rerender():
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
if request.method == 'POST':
try:
data = TryoutSchema().load(tryout_form_payload())
except ValidationError as err:
flash_validation_errors(err)
return rerender()
tryout.title = data['title']
tryout.description = data['description']
tryout.game = data['game']
tryout.date = data['date']
tryout.end_date = data['end_date']
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'])
db.session.commit()
flash(_('Tryout updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return rerender()
@tryouts_bp.route('/<int:tryout_id>')
@login_required
def view_tryout(tryout_id):
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
tryout = Tryout.query.get_or_404(tryout_id)
can_view = False
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
elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player):
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id
).first()
is not None
)
player_in_match = (
MatchParticipant.query.join(Match)
.filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.first()
is not None
)
can_view = is_registered or player_in_match
elif isinstance(current_user, Scout):
can_view = True
if not can_view:
flash(_('You do not have permission to view this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
# Everything below used to run one query per row (PERF-001): one
# User.query.get() per registration, one Evaluation lookup per player,
# one TeamMember query per team and one more User.query.get() per
# member. Thirty registrants and four teams put this page well past a
# hundred round trips, on unindexed columns.
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
registered_player_ids = [r.player_id for r in registrations if r.player_id]
players_by_id = _users_by_id(registered_player_ids)
registered_players = [
players_by_id[player_id]
for player_id in registered_player_ids
if player_id in players_by_id
]
evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all()
player_eval_status = {}
if current_user.can_evaluate():
evaluated_by_me = {
row.player_id
for row in evaluations
if row.evaluator_id == current_user.id and row.player_id
}
player_eval_status = {p.id: p.id in evaluated_by_me for p in registered_players}
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id,
player_id=current_user.id,
).first()
is not None
)
teams = Team.query.filter_by(tryout_id=tryout_id).all()
team_ids = [team.id for team in teams]
members_by_team = {}
if team_ids:
member_rows = TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).all()
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
for row in member_rows:
members_by_team.setdefault(row.team_id, []).append(
{'player': member_players.get(row.player_id), 'position': row.position}
)
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
can_edit = current_user.can_manage_this_tryout(tryout)
can_view_calendar = can_edit
if isinstance(current_user, Player):
player_in_match = (
MatchParticipant.query.join(Match)
.filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.first()
is not None
)
can_view_calendar = is_registered or player_in_match
all_players = None
if can_edit:
# is_active_account, like the manager and coach queries in this same
# module. Offering a deactivated account in a roster select
# contradicts the one control that says the person has left.
all_players = (
User.query.filter_by(role='player', is_active_account=True)
.order_by(User.username)
.all()
)
matches = (
Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
)
match_data = []
for match in matches:
all_participants = list(match.participants.all())
confirmed_count = sum(1 for p in all_participants if p.attendance_confirmed)
total_count = len(all_participants)
player_presence = []
for p in all_participants:
if p.player:
player_presence.append(
{
'participant_id': p.id,
'player_id': p.player_id,
'player_name': p.player.username,
'attendance_confirmed': p.attendance_confirmed,
}
)
if match.match_type == 'team_vs_team':
participants = {
'team1': match.team1.name if match.team1 else 'TBD',
'team2': match.team2.name if match.team2 else 'TBD',
'team1_players': [
{'name': m.player.username, 'position': m.position}
for m in match.team1.members.all()
]
if match.team1
else [],
'team2_players': [
{'name': m.player.username, 'position': m.position}
for m in match.team2.members.all()
]
if match.team2
else [],
}
elif match.match_type == 'player_vs_player':
# Filtered from the list already in hand. Asking the dynamic
# relationship again cost two more round trips per match for
# rows that were loaded a dozen lines above.
team1_players = [
{'name': p.player.username, 'position': p.position}
for p in all_participants
if p.team_side == 1 and p.player
]
team2_players = [
{'name': p.player.username, 'position': p.position}
for p in all_participants
if p.team_side == 2 and p.player
]
participants = {
'team1': 'Team 1',
'team2': 'Team 2',
'team1_players': team1_players,
'team2_players': team2_players,
}
else:
participants = [p.player.username for p in match.participants.all()]
match_data.append(
{
'match': match,
'participants': participants,
'confirmed_count': confirmed_count,
'total_count': total_count,
'player_presence': player_presence,
}
)
return render_template(
'pages/view_tryout.html',
tryout=tryout,
registered_players=registered_players,
evaluations=evaluations,
player_eval_status=player_eval_status,
is_registered=is_registered,
registrations=registrations,
team_data=team_data,
can_edit=can_edit,
can_view_calendar=can_view_calendar,
all_players=all_players,
matches=matches,
match_data=match_data,
game_positions=GAME_POSITIONS,
now=datetime.utcnow(),
)
@tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
@login_required
def register_for_tryout(tryout_id):
"""Register a player for a tryout. Only Players can self-register."""
tryout = Tryout.query.get_or_404(tryout_id)
if not isinstance(current_user, Player):
flash(_('Only players can register for tryouts.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.status not in ['upcoming', 'in_progress']:
flash(_('This tryout is not accepting registrations.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id
).first()
if existing:
flash(_('You are already registered for this tryout.'), 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players:
flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
db.session.add(registration)
db.session.commit()
flash(_('Successfully registered for tryout!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
@login_required
def update_status(tryout_id):
"""Update the status of a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
new_status = request.form.get('status')
if new_status in ['upcoming', 'in_progress', 'completed']:
tryout.status = new_status
db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
@login_required
def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
).first_or_404()
new_status = request.form.get('status')
if new_status in ['registered', 'attended', 'no_show']:
registration.status = new_status
db.session.commit()
flash(_('Registration status updated.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
@login_required
def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches)."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
try:
data = PlayerSelectionSchema().load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not data['player_id']:
flash(_('Please select a player.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# Same two checks as the team roster (SEC-16): the right role, and an
# account that has not been deactivated. The select this comes from now
# filters both, but the select is not the control.
player = db.session.get(User, data['player_id'])
if not player or not isinstance(player, Player) or not player.is_active_account:
flash(_('Can only register players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
if existing:
flash(
_('%(username)s is already registered for this tryout.', username=player.username),
'info',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players:
flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
db.session.add(registration)
db.session.commit()
flash(_('%(username)s registered for tryout!', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
@login_required
def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
player = User.query.get_or_404(player_id)
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
).first()
if registration:
db.session.delete(registration)
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
if team_ids:
TeamMember.query.filter(
TeamMember.team_id.in_(team_ids),
TeamMember.player_id == player_id,
).delete(synchronize_session=False)
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids),
MatchParticipant.player_id == player_id,
).delete(synchronize_session=False)
db.session.commit()
flash(_('%(username)s removed from tryout.', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
@login_required
def create_team(tryout_id):
"""Create a tryout-specific team."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team_name = request.form.get('team_name')
if team_name:
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
@login_required
def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team."""
team = Team.query.get_or_404(team_id)
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# The two ids arrive independently in the URL. Without this check, being
# allowed to manage tryout A was enough to modify a team belonging to
# tryout B, since only the tryout was authorised.
if team.tryout_id != tryout_id:
abort(404)
player_id = request.form.get('player_id', type=int)
if not player_id:
flash(_('Please select a player.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# Only players registered for this tryout may be placed on its teams.
is_registered = (
TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first()
is not None
)
if not is_registered:
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
position = request.form.get('position', '')
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing:
flash(_('Player is already on this team.'), 'info')
else:
member = TeamMember(team_id=team_id, player_id=player_id, position=position)
db.session.add(member)
db.session.commit()
flash(_('Player added to team!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
@login_required
def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
# Personal notes outlive the tryout: they are a coach's observations
# about a player, not tryout data. Only their context links are cleared.
# Missing this step made the deletion fail on the foreign keys below.
PersonalNote.query.filter_by(tryout_id=tryout_id).update(
{'tryout_id': None}, synchronize_session=False
)
if match_ids:
PersonalNote.query.filter(PersonalNote.match_id.in_(match_ids)).update(
{'match_id': None}, synchronize_session=False
)
if team_ids:
PersonalNote.query.filter(PersonalNote.team_id.in_(team_ids)).update(
{'team_id': None}, synchronize_session=False
)
if match_ids:
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids)).delete(
synchronize_session=False
)
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
if team_ids:
TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).delete(synchronize_session=False)
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
Evaluation.query.filter_by(tryout_id=tryout_id).delete()
db.session.delete(tryout)
db.session.commit()
flash(_('Tryout deleted successfully.'), 'success')
return redirect(url_for('tryouts.list_tryouts'))
+36
View File
@@ -0,0 +1,36 @@
"""User-facing routes, split by subject.
Was a single 1 699-line module covering account administration, profiles,
availability calendars, contracts, one-on-one sessions and coach notes
six subjects that shared nothing but a URL prefix (ARCH-004).
Importing this package registers every route on `users_bp`, so app.py
keeps its single `from app.routes.users import users_bp`. The blueprint
itself lives in blueprint.py to keep that import one-directional.
"""
# 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
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
ALLOWED_CONTRACT_EXTENSIONS,
ALLOWED_SIGNED_EXTENSIONS,
pdf_upload_error,
)
from app.routes.users.blueprint import users_bp
__all__ = [
'ALLOWED_CONTRACT_EXTENSIONS',
'ALLOWED_SIGNED_EXTENSIONS',
'pdf_upload_error',
'users_bp',
]
+85
View File
@@ -0,0 +1,85 @@
"""Helpers used by more than one route module in this package.
Nothing here touches the blueprint: these are plain functions, so a test
can call them with a request context and nothing else.
"""
from flask import request
from flask_babel import gettext as _
from app.extensions import db
# Re-exported: these two moved to app/forms.py once the match and tryout
# routes needed them as well (ARCH-005). Importing them from here still
# works, so the thirty call sites in this package did not have to move.
from app.forms import flash_validation_errors, form_payload # noqa: F401
from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
#: Every PDF starts with this. Checking the name alone accepted a file
#: called anything.pdf holding anything at all.
PDF_SIGNATURE = b'%PDF-'
#: USER_TYPE → model class, for create_user.
USER_CLASS_MAP = {
'admin': Admin,
'manager': Manager,
'coach': Coach,
'player': Player,
'scout': Scout,
}
def pdf_upload_error(file, allowed_extensions):
"""Why this upload is not an acceptable PDF, or None if it is.
upload_signed_contract checked nothing beyond a non-empty filename
ALLOWED_SIGNED_EXTENSIONS was declared and never read so a player
could put an arbitrary file on the server under a name the application
later hands back for download (SEC-021).
Args:
file: The uploaded FileStorage, or None.
allowed_extensions: Extensions to accept, lowercase and without dot.
Returns:
str | None: A message to flash, or None when the file is acceptable.
"""
if file is None or not file.filename:
return _('No file selected.')
stem, dot, extension = file.filename.rpartition('.')
if not (stem and dot) or extension.lower() not in allowed_extensions:
return _('Only PDF files are allowed for contracts.')
head = file.stream.read(len(PDF_SIGNATURE))
file.stream.seek(0)
if head != PDF_SIGNATURE:
return _('That file is not a PDF, whatever its name says.')
return None
def update_user_gamertags(user, selected_games):
"""Update gamertags for a user based on form input."""
existing_gamertags = {gt.game: gt for gt in user.gamertags}
for game in selected_games:
gamertag = request.form.get(f'gamertag_{game}', '').strip()
platform = (
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
)
existing = existing_gamertags.get(game)
if gamertag:
if existing:
existing.gamertag = gamertag
existing.platform = platform
else:
gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform)
db.session.add(gt)
elif existing:
db.session.delete(existing)
for game in existing_gamertags:
if game not in selected_games:
db.session.delete(existing_gamertags[game])
+379
View File
@@ -0,0 +1,379 @@
"""Account administration — the president's view of the user list.
Creating, editing, deleting and viewing accounts. Everything here is
admin-only except view_user, which renders a public profile.
"""
from flask import flash, 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 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,
Evaluation,
Match,
MatchParticipant,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Player,
PlayerDisponibility,
Team,
TeamMember,
TeamNote,
TeamPlayer,
Tryout,
TryoutRegistration,
User,
UserGamertag,
)
from app.pagination import paginate
from app.routes.users._shared import (
USER_CLASS_MAP,
flash_validation_errors,
form_payload,
update_user_gamertags,
)
from app.routes.users.blueprint import users_bp
from app.storage import discard_documents
from app.validators import CreateUserSchema, EditUserSchema
@users_bp.route('')
@login_required
def list_users():
"""List all users for management (Admin only)."""
if not isinstance(current_user, Admin):
flash(_('Only the president can manage users.'), 'danger')
return redirect(url_for('main.dashboard'))
# Ordered before paginated, and by a unique-enough key: a paginated
# query without a stable ORDER BY can show the same row twice and never
# show another (MNT-14).
page = paginate(User.query.order_by(User.role, User.username, User.id))
return render_template('pages/users.html', users=page.items, pagination=page, roles=USER_TYPES)
@users_bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_user(user_id):
"""Edit an existing user (Admin only)."""
if not isinstance(current_user, Admin):
flash(_('Only the president can edit users.'), 'danger')
return redirect(url_for('main.dashboard'))
user = User.query.get_or_404(user_id)
if request.method == 'POST':
actor_name, actor_id = current_user.username, current_user.id
def _rerender():
return render_template(
'pages/edit_user.html',
user=user,
roles=USER_TYPES,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags={
gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform}
for gt in user.gamertags
},
)
try:
validated = EditUserSchema().load(form_payload(checkboxes=('is_active_account',)))
except ValidationError as err:
flash_validation_errors(err)
return _rerender()
full_name = validated['full_name']
email = validated['email']
phone = validated.get('phone')
role = validated['role']
is_active = validated['is_active_account']
selected_games = validated.get('games', [])
discord_username = validated.get('discord_username')
discord_user_id = validated.get('discord_user_id')
league_os_profile = validated.get('league_os_profile')
# Previously absent: the column is unique, so assigning a taken
# address surfaced as an IntegrityError, i.e. a 500.
clash = User.query.filter(User.email == email, User.id != user.id).first()
if clash:
flash(_('Email already in use by another account.'), 'danger')
return _rerender()
discord_clash = None
if discord_user_id:
discord_clash = User.query.filter(
User.discord_user_id == discord_user_id,
User.id != user.id,
).first()
if discord_clash:
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender()
role_changed = user.role != role
previous_role = user.role
if role_changed:
# Two ways to lock everyone out of administration, neither of
# which any interface can undo afterwards.
if user.id == actor_id:
flash(
_('You cannot change your own role. Ask another president to do it.'), 'danger'
)
return _rerender()
if user.role == 'admin':
remaining_admins = User.query.filter(
User.role == 'admin',
User.is_active_account.is_(True),
User.id != user.id,
).count()
if remaining_admins == 0:
flash(
_(
'This is the last active president. Promote '
'another account before changing this one.'
),
'danger',
)
return _rerender()
if role_changed:
# The role column is the polymorphic discriminator, and SQLAlchemy
# decides an instance's class when it loads it. Assigning to it
# through the ORM leaves a Player object in the identity map for a
# row that now says 'coach', so every later isinstance() check —
# which is how this application does authorisation — answers with
# the old role. Hence the statement-level UPDATE.
#
# The instance then has to be re-read. This used to call
# db.session.remove(), which throws away the whole session:
# everything the request still held was detached, current_user
# included, and the next attribute access on any of them raised
# DetachedInstanceError. Expunging the one stale instance is
# enough, and it leaves the transaction open — so the role change
# and the rest of the edit now commit together instead of the
# role landing on its own and the remaining fields failing after
# it (ARCH-008).
user_pk = user.id
db.session.execute(
db.text('UPDATE users SET role = :role WHERE id = :id'),
{'role': role, 'id': user_pk},
)
db.session.expunge(user)
user = db.session.get(User, user_pk)
user.full_name = full_name
user.email = email
user.phone = phone
user.is_active_account = is_active
user.games = ','.join(selected_games) if selected_games else None
user.discord_username = discord_username or None
user.discord_user_id = discord_user_id or None
user.league_os_profile = league_os_profile or None
update_user_gamertags(user, selected_games)
# Blank means "keep the current password"; anything else has already
# been checked against the policy by the schema.
password = validated.get('password')
if password:
user.password_hash = hash_password(password)
db.session.commit()
# Logged after the commit, not before: the audit trail should record
# what happened, and until this point nothing had.
if role_changed:
log_auth_event(
'account.role_changed',
actor=actor_name,
actor_id=actor_id,
target=user.username,
target_id=user.id,
previous_role=previous_role,
new_role=role,
)
if password:
log_auth_event(
'account.password_reset_by_admin',
actor=actor_name,
actor_id=actor_id,
target=user.username,
target_id=user.id,
)
log_auth_event(
'account.updated',
actor=actor_name,
actor_id=actor_id,
target=user.username,
target_id=user.id,
active=is_active,
)
flash(_('User %(username)s updated successfully!', username=user.username), 'success')
return redirect(url_for('users.list_users'))
user_gamertags = {
gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in user.gamertags
}
return render_template(
'pages/edit_user.html',
user=user,
roles=USER_TYPES,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=user_gamertags,
)
@users_bp.route('/<int:user_id>/delete', methods=['POST'])
@login_required
def delete_user(user_id):
"""Delete a user (Admin only)."""
if not isinstance(current_user, Admin):
flash(_('Only the president can delete users.'), 'danger')
return redirect(url_for('main.dashboard'))
if current_user.id == user_id:
flash(_('You cannot delete your own account.'), 'danger')
return redirect(url_for('users.list_users'))
user = User.query.get_or_404(user_id)
Evaluation.query.filter(
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
).delete(synchronize_session=False)
PlayerDisponibility.query.filter_by(player_id=user_id).delete()
CoachAvailability.query.filter_by(coach_id=user_id).delete()
PersonalNote.query.filter(
db.or_(PersonalNote.player_id == user_id, PersonalNote.coach_id == user_id),
).delete(synchronize_session=False)
TeamNote.query.filter_by(coach_id=user_id).delete()
OneOnOneRequest.query.filter(
db.or_(OneOnOneRequest.player_id == user_id, OneOnOneRequest.coach_id == user_id),
).delete(synchronize_session=False)
UserGamertag.query.filter_by(user_id=user_id).delete()
# Read the file paths before the rows go: afterwards there is nothing
# left to say where the PDFs are (DATA-012). The files themselves are
# removed after the commit, below.
contract_files = [
path
for contract in Contract.query.filter_by(player_id=user_id).all()
for path in (contract.file_path, contract.signed_file_path)
]
Contract.query.filter_by(player_id=user_id).delete()
TryoutRegistration.query.filter_by(player_id=user_id).delete()
TeamPlayer.query.filter_by(player_id=user_id).delete()
TeamMember.query.filter_by(player_id=user_id).delete()
MatchParticipant.query.filter_by(player_id=user_id).delete()
OrgTeam.query.filter_by(coach_id=user_id).update({'coach_id': None})
OrgTeam.query.filter_by(manager_id=user_id).update({'manager_id': None})
Tryout.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
Match.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
Team.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
OrgTeam.query.filter_by(created_by=user_id).update({'created_by': current_user.id})
Contract.query.filter_by(uploaded_by_id=user_id).update({'uploaded_by_id': current_user.id})
deleted_username, deleted_role = user.username, user.role
db.session.delete(user)
db.session.commit()
# After the commit, deliberately. A failure here leaves a file with no
# row — recoverable, and exactly what happened before this existed —
# rather than a row with no file, which is a download that 500s for ever.
discarded = discard_documents(contract_files)
log_auth_event(
'account.deleted',
actor=current_user.username,
actor_id=current_user.id,
target=deleted_username,
target_id=user_id,
role=deleted_role,
contract_files_removed=discarded,
)
flash(
_('User %(deleted_username)s has been removed.', deleted_username=deleted_username),
'success',
)
return redirect(url_for('users.list_users'))
@users_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_user():
"""Create a new user (Admin only). Uses the correct polymorphic subclass."""
if not isinstance(current_user, Admin):
flash(_('Only the president can create users.'), 'danger')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
try:
validated = CreateUserSchema().load(request.form)
except ValidationError as err:
flash_validation_errors(err)
return render_template('pages/create_user.html', roles=USER_TYPES)
username = validated['username']
email = validated['email']
password = validated['password']
full_name = validated['full_name']
phone = validated.get('phone')
# The schema constrains role with OneOf(USER_TYPES), so the former
# manual membership check is now redundant.
role = validated['role']
if User.query.filter_by(username=username).first():
flash(_('Username already exists.'), 'danger')
return render_template('pages/create_user.html', roles=USER_TYPES)
if User.query.filter_by(email=email).first():
flash(_('Email already registered.'), 'danger')
return render_template('pages/create_user.html', roles=USER_TYPES)
hashed_password = hash_password(password)
user_cls = USER_CLASS_MAP.get(role, Player)
user = user_cls(
username=username,
password_hash=hashed_password,
role=role,
full_name=full_name,
email=email,
phone=phone,
)
db.session.add(user)
db.session.commit()
log_auth_event(
'account.created_by_admin',
actor=current_user.username,
actor_id=current_user.id,
target=user.username,
target_id=user.id,
role=role,
)
flash(
_('User %(full_name)s created as %(role)s!', full_name=full_name, role=role), 'success'
)
return redirect(url_for('users.list_users'))
return render_template('pages/create_user.html', roles=USER_TYPES)
@users_bp.route('/<int:user_id>/view')
@login_required
def view_user(user_id):
"""View a public profile for any user."""
user = User.query.get_or_404(user_id)
return render_template('pages/view_user.html', profile_user=user)
+270
View File
@@ -0,0 +1,270 @@
"""When people are free.
Two calendars that share a shape without sharing a purpose: a player's
weekly availability blocks, and a coach's bookable slots for one-on-one
sessions.
"""
from flask import 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 app.api import json_endpoint
from app.extensions import db
from app.forms import form_payload
from app.models import Coach, CoachAvailability, PlayerDisponibility, User
from app.routes.users.blueprint import users_bp
from app.timeslots import day_name, slot_end
from app.validators import TimeSlotSchema
def _load_slots(payload_slots):
"""Validate a batch of posted slots, keeping the rejects.
Both bulk endpoints used to `continue` past anything malformed and then
answer `{'success': True}`. The client had no way to learn that a slot
had been dropped and for coach availability that is destructive, since
the route deletes every existing slot before re-adding the ones it
accepted. A payload the browser mangled could therefore wipe a coach's
bookable hours and report success (MNT-12).
Args:
payload_slots: Whatever arrived under the `slots` key.
Returns:
tuple[list[dict], list[str]]: Accepted slots, and one message per
rejected one.
"""
schema = TimeSlotSchema()
accepted, rejected = [], []
for index, raw in enumerate(payload_slots or []):
if not isinstance(raw, dict):
rejected.append(f'slot {index}: expected an object')
continue
try:
accepted.append(schema.load(raw))
except ValidationError as err:
details = '; '.join(
f'{field}: {" ".join(str(m) for m in messages)}'
for field, messages in err.messages.items()
)
rejected.append(f'slot {index}: {details}')
return accepted, rejected
@users_bp.route('/disponibilities')
@json_endpoint
@login_required
def get_disponibilities():
"""API endpoint to get all player disponibilities for scheduling."""
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
return jsonify({'error': 'Unauthorized'}), 403
players = (
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
)
result = {}
for player in players:
disponibilities = list(player.disponibilities)
result[player.id] = {
'username': player.username,
'disponibilities': [
{
'id': d.id,
'day_of_week': d.day_of_week,
'day_name': day_name(d.day_of_week),
'start_time': d.start_time.strftime('%H:%M'),
'end_time': d.end_time.strftime('%H:%M'),
}
for d in disponibilities
],
}
return jsonify(result)
@users_bp.route('/disponibilities/my')
@json_endpoint
@login_required
def get_my_disponibilities():
"""API endpoint for players to get their own disponibilities."""
disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all()
result = {}
for d in disponibilities:
day = d.day_of_week
if day not in result:
result[day] = []
result[day].append(
{
'id': d.id,
'day_of_week': d.day_of_week,
'day_name': day_name(d.day_of_week),
'start_time': d.start_time.strftime('%H:%M'),
'end_time': d.end_time.strftime('%H:%M'),
}
)
return jsonify(result)
@users_bp.route('/disponibilities/add', methods=['POST'])
@json_endpoint
@login_required
def add_disponibility():
"""Add a disponibility block for the current player."""
try:
slot = TimeSlotSchema().load(form_payload())
except ValidationError as err:
return jsonify({'error': 'Invalid slot', 'details': err.messages}), 400
day_of_week = slot['day_of_week']
start_time = slot['start_time']
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=day_of_week,
start_time=start_time,
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.commit()
return jsonify(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
'end_time': disponibility.end_time.strftime('%H:%M'),
}
)
@users_bp.route('/disponibilities/add_bulk', methods=['POST'])
@json_endpoint
@login_required
def add_disponibilities_bulk():
"""Replace the current player's disponibility blocks atomically."""
data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots'))
if rejected:
return jsonify(
{
'error': 'Invalid slots; nothing was changed.',
'rejected': rejected,
}
), 400
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
created = []
for slot in accepted:
start_time = slot['start_time']
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.flush()
created.append(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
}
)
db.session.commit()
return jsonify({'success': True, 'created': created, 'rejected': []})
@users_bp.route('/disponibilities/clear', methods=['POST'])
@json_endpoint
@login_required
def clear_disponibilities():
"""Clear all disponibilities for the current player."""
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
db.session.commit()
return jsonify({'success': True})
@users_bp.route('/disponibilities/<int:disponibility_id>/delete', methods=['POST'])
@json_endpoint
@login_required
def delete_disponibility(disponibility_id):
"""Delete a disponibility block."""
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
if disponibility.player_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
db.session.delete(disponibility)
db.session.commit()
return jsonify({'success': True})
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
@json_endpoint
@login_required
def manage_coach_availability():
"""Manage coach availability for One on One sessions."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can manage availability.'), 'danger')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots'))
# Validate everything before deleting anything.
#
# This route replaces the coach's availability: it deleted every
# existing slot and then re-added the ones it could parse, skipping
# the rest in silence and answering `{'success': true}`. A payload
# the browser mangled therefore wiped a coach's bookable hours and
# reported success — and one-on-one requests are refused against
# exactly this table, so the coach became unbookable with nothing to
# show for it. Refusing the whole batch is the only safe answer when
# the operation is a replacement (MNT-12).
if rejected:
return jsonify(
{
'error': 'Invalid slots; nothing was changed.',
'rejected': rejected,
}
), 400
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
for slot in accepted:
start_time = slot['start_time']
db.session.add(
CoachAvailability(
coach_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
end_time=slot_end(start_time),
)
)
db.session.commit()
return jsonify({'success': True, 'saved': len(accepted)})
existing_availability = CoachAvailability.query.filter_by(
coach_id=current_user.id,
).all()
return render_template(
'pages/coach_availability.html', existing_availability=existing_availability
)
@users_bp.route('/coach-availability/clear', methods=['POST'])
@json_endpoint
@login_required
def clear_coach_availability():
"""Clear all coach availability slots."""
if not isinstance(current_user, Coach):
return jsonify({'error': 'Unauthorized'}), 403
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
db.session.commit()
return jsonify({'success': True})
+16
View File
@@ -0,0 +1,16 @@
"""The `users` blueprint object, on its own.
Every route module in this package imports it from here rather than from
the package __init__, so there is no import cycle to reason about and no
ordering constraint between the modules.
The blueprint stays a *single* blueprint even though the package holds six
route modules. Splitting it into `users_accounts`, `users_contracts` and so
on would rename 137 endpoints, and every one of them is spelled out in a
`url_for('users.…')` somewhere in the templates. The goal of ARCH-004 is a
file you can read, not a URL map you have to relearn.
"""
from flask import Blueprint
users_bp = Blueprint('users', __name__, url_prefix='/users')
+211
View File
@@ -0,0 +1,211 @@
"""Player contracts: upload, sign, download."""
import os
import uuid
from datetime import datetime
from flask import flash, redirect, render_template, request, send_file, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from werkzeug.utils import secure_filename
from app.extensions import db
from app.models import Admin, Coach, Contract, Manager, Player, User
from app.permissions import can_manage_player_contract, coach_player_ids
from app.routes.users._shared import (
ALLOWED_CONTRACT_EXTENSIONS,
ALLOWED_SIGNED_EXTENSIONS,
pdf_upload_error,
)
from app.routes.users.blueprint import users_bp
from app.storage import CONTRACTS_DIR, document_path
from app.validators import UploadContractSchema
def manageable_players():
"""Players the current user may attach a contract to.
A coach used to see the squad of one team the first row matching the
legacy coach_id column so a coach of two teams could file a contract
for half of their players and no more, and a coach attached only by the
many-to-many relationship for none at all.
"""
if isinstance(current_user, Coach):
player_ids = coach_player_ids(current_user)
return (
User.query.filter(User.id.in_(player_ids)).order_by(User.username).all()
if player_ids
else []
)
# is_active_account: a contract select that still lists people who have
# left the club invites filing paperwork against them (SEC-16).
return User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
@users_bp.route('/contracts')
@login_required
def list_contracts():
"""View contracts for the current user or players they manage."""
contracts = None
players = None
if isinstance(current_user, Player):
contracts = (
Contract.query.filter_by(
player_id=current_user.id,
)
.order_by(Contract.uploaded_at.desc())
.all()
)
elif isinstance(current_user, (Admin, Manager, Coach)):
players = manageable_players()
if players:
player_ids = [p.id for p in players]
contracts = (
Contract.query.filter(
Contract.player_id.in_(player_ids),
)
.order_by(Contract.uploaded_at.desc())
.all()
)
return render_template(
'pages/contracts.html',
contracts=contracts,
players=players if isinstance(current_user, (Admin, Manager, Coach)) else None,
)
@users_bp.route('/contracts/upload', methods=['GET', 'POST'])
@login_required
def upload_contract():
"""Upload a contract for a player."""
if not isinstance(current_user, (Admin, Manager, Coach)):
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
return redirect(url_for('users.list_contracts'))
players = manageable_players()
if request.method == 'POST':
contract_schema = UploadContractSchema()
try:
validated = contract_schema.load(request.form)
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return render_template('pages/upload_contract.html', players=players)
player_id = validated['player_id']
notes = validated.get('notes')
if not can_manage_player_contract(current_user, player_id):
flash(_('You do not have permission to upload a contract for this player.'), 'danger')
return redirect(url_for('users.upload_contract'))
file = request.files.get('contract_file')
error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS)
if error:
flash(error, 'danger')
return redirect(url_for('users.upload_contract'))
player = User.query.get_or_404(player_id)
player_teams = player.get_org_teams()
team = player_teams[0] if player_teams else None
original_filename = secure_filename(file.filename)
stored_filename = f"{uuid.uuid4()}.pdf"
# Kept relative to the document root, not absolute (see app/storage.py):
# an absolute path pins the file to the directory the process was
# started from, which is the one thing a release-directory deploy
# changes.
relative_path = os.path.join(CONTRACTS_DIR, stored_filename)
if team:
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
absolute_path = document_path(relative_path)
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
file.save(absolute_path)
contract = Contract(
player_id=player_id,
team_id=team.id if team else None,
uploaded_by_id=current_user.id,
original_filename=original_filename,
stored_filename=stored_filename,
file_path=relative_path,
notes=notes if notes else None,
)
db.session.add(contract)
db.session.commit()
flash(
_('Contract uploaded successfully for %(username)s!', username=player.username),
'success',
)
return redirect(url_for('users.list_contracts'))
return render_template('pages/upload_contract.html', players=players)
@users_bp.route('/contracts/<int:contract_id>/upload_signed', methods=['POST'])
@login_required
def upload_signed_contract(contract_id):
"""Upload a signed contract (player only)."""
contract = Contract.query.get_or_404(contract_id)
if not contract.can_upload_signed(current_user):
flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
file = request.files.get('signed_file')
error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS)
if error:
flash(error, 'danger')
return redirect(url_for('users.list_contracts'))
signed_filename = f"signed_{contract.stored_filename}"
signed_path = contract.file_path.replace(contract.stored_filename, signed_filename)
file.save(document_path(signed_path))
contract.signed_filename = signed_filename
contract.signed_file_path = signed_path
contract.status = 'signed'
contract.signed_at = datetime.utcnow()
db.session.commit()
flash(_('Signed contract uploaded successfully!'), 'success')
return redirect(url_for('users.list_contracts'))
@users_bp.route('/contracts/<int:contract_id>/download')
@login_required
def download_contract(contract_id):
"""Download a contract file."""
contract = Contract.query.get_or_404(contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(
document_path(contract.file_path),
as_attachment=True,
download_name=contract.original_filename,
)
@users_bp.route('/contracts/<int:contract_id>/download_signed')
@login_required
def download_signed_contract(contract_id):
"""Download a signed contract file."""
contract = Contract.query.get_or_404(contract_id)
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
if not contract.signed_file_path:
flash(_('No signed contract available.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(
document_path(contract.signed_file_path),
as_attachment=True,
download_name=contract.signed_filename,
)
+376
View File
@@ -0,0 +1,376 @@
"""Notes a coach keeps: about a team, and about individual players.
The player-facing view of the same notes lives here too my_notes since
it reads exactly what the coach routes write.
"""
from flask import 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 (
Coach,
Match,
MatchParticipant,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Player,
TeamNote,
Tryout,
TryoutRegistration,
User,
)
from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids
from app.routes.users.blueprint import users_bp
@users_bp.route('/my-notes')
@login_required
def my_notes():
"""View personal and team notes for the current player."""
if not isinstance(current_user, Player):
flash(_('This page is for players only.'), 'info')
return redirect(url_for('main.dashboard'))
org_teams = current_user.get_org_teams()
org_team = org_teams[0] if org_teams else None
personal_notes = (
PersonalNote.query.filter_by(
player_id=current_user.id,
)
.order_by(PersonalNote.created_at.desc())
.all()
)
team_notes = []
if org_team:
team_notes = (
TeamNote.query.filter_by(
org_team_id=org_team.id,
)
.order_by(TeamNote.created_at.desc())
.all()
)
return render_template(
'pages/player_personal_notes.html',
org_team=org_team,
personal_notes=personal_notes,
team_notes=team_notes,
)
@users_bp.route('/notes-dashboard')
@login_required
def notes_dashboard():
"""Notes and One on One dashboard for coaches."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can access the notes dashboard.'), 'danger')
return redirect(url_for('main.dashboard'))
# The team-notes panel is still written against a single team; the
# player list is not, and used to be narrowed to one team's squad while
# the POST routes accepted every player the coach works with. The form
# offered fewer players than the handler would take.
org_teams = coach_org_teams(current_user)
org_team = org_teams[0] if org_teams else None
player_ids = coach_player_ids(current_user)
players = (
User.query.filter(User.id.in_(player_ids)).order_by(User.username).all()
if player_ids
else []
)
team_notes = []
latest_team_note = None
if org_team:
team_notes = (
TeamNote.query.filter_by(
org_team_id=org_team.id,
)
.order_by(TeamNote.created_at.desc())
.all()
)
latest_team_note = team_notes[0] if team_notes else None
# A coach's own notes belong to them whether or not they hold a team;
# this list was gated on org_team and came back empty without one.
personal_notes = (
PersonalNote.query.filter_by(
coach_id=current_user.id,
)
.order_by(PersonalNote.created_at.desc())
.all()
)
one_on_one_requests = []
if player_ids:
one_on_one_requests = (
OneOnOneRequest.query.filter(OneOnOneRequest.player_id.in_(player_ids))
.order_by(OneOnOneRequest.created_at.desc())
.all()
)
# For context selectors in the form
matches = (
Match.query.filter(
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
)
.order_by(Match.date.desc())
.limit(20)
.all()
)
tryouts = (
Tryout.query.filter_by(
created_by=current_user.id,
)
.order_by(Tryout.date.desc())
.limit(20)
.all()
)
teams = OrgTeam.query.order_by(OrgTeam.name).all()
return render_template(
'pages/notes.html',
org_team=org_team,
players=players,
team_notes=team_notes,
latest_team_note=latest_team_note,
personal_notes=personal_notes,
one_on_one_requests=one_on_one_requests,
matches=matches,
tryouts=tryouts,
teams=teams,
)
# ---------------------------------------------------------------------------
# Manage Team Notes (POST)
# ---------------------------------------------------------------------------
@users_bp.route('/team-notes/manage', methods=['POST'])
@login_required
def manage_team_notes():
"""Create or update team notes for the coach's org team."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can manage team notes.'), 'danger')
return redirect(url_for('main.dashboard'))
# Same team the dashboard displays notes for, resolved the same way.
org_teams = coach_org_teams(current_user)
if not org_teams:
flash(_('You are not assigned to a team.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
org_team = org_teams[0]
content = request.form.get('content', '').strip()
if content:
note = TeamNote(
org_team_id=org_team.id,
coach_id=current_user.id,
content=content,
)
db.session.add(note)
db.session.commit()
flash(_('Team notes saved successfully!'), 'success')
return redirect(url_for('users.notes_dashboard'))
# ---------------------------------------------------------------------------
# Manage Personal Notes (POST, simple form)
# ---------------------------------------------------------------------------
@users_bp.route('/personal-notes/manage', methods=['POST'])
@login_required
def manage_personal_notes():
"""Create a personal note for a player (coach only, simple form)."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can manage personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
player = User.query.get_or_404(player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
)
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard'))
# ---------------------------------------------------------------------------
# Add Personal Note (POST, full form with context)
# ---------------------------------------------------------------------------
@users_bp.route('/personal-notes/add', methods=['POST'])
@login_required
def add_personal_note():
"""Create a personal note for a player with optional context (coach only)."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
match_id = request.form.get('match_id', type=int)
tryout_id = request.form.get('tryout_id', type=int)
team_id_str = request.form.get('team_id')
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
player = User.query.get_or_404(player_id)
if not isinstance(player, Player):
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
match_id=match_id if match_id else None,
tryout_id=tryout_id if tryout_id else None,
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None,
)
db.session.add(note)
db.session.commit()
flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard'))
# ---------------------------------------------------------------------------
# Add Note from Tryout context (GET + POST)
# ---------------------------------------------------------------------------
@users_bp.route('/personal-notes/tryout/<int:tryout_id>', methods=['GET', 'POST'])
@login_required
def add_note_from_tryout(tryout_id):
"""Add a personal note for a player in the context of a tryout."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
preselected_player_id = request.args.get('player_id', type=int)
# Get registrations as players for the select list
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
players = [r.player for r in registrations if r.player]
if request.method == 'POST':
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
tryout_id=tryout_id,
)
db.session.add(note)
db.session.commit()
flash(_('Note added successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template(
'pages/add_note.html',
context_type='tryout',
tryout=tryout,
players=players,
preselected_player_id=preselected_player_id,
team_notes=[],
)
# ---------------------------------------------------------------------------
# Add Note from Match context (GET + POST)
# ---------------------------------------------------------------------------
@users_bp.route('/personal-notes/match/<int:match_id>', methods=['GET', 'POST'])
@login_required
def add_note_from_match(match_id):
"""Add a personal note for a player in the context of a match."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
match_obj = Match.query.get_or_404(match_id)
# Get participants as players for the select list
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
players = [p.player for p in participants if p.player]
preselected_player_id = request.args.get('player_id', type=int)
if request.method == 'POST':
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
if not player_id or not content:
flash(_('Player and content are required.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not coach_can_access_player(current_user, player_id):
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=content,
match_id=match_id,
)
db.session.add(note)
db.session.commit()
flash(_('Note added successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=match_obj.tryout_id))
return render_template(
'pages/add_note.html',
context_type='match',
tryout=match_obj,
match=match_obj,
players=players,
preselected_player_id=preselected_player_id,
team_notes=[],
)
+263
View File
@@ -0,0 +1,263 @@
"""One-on-one sessions between a player and their coach."""
from datetime import datetime
from flask import flash, 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 app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
from app.routes.users.blueprint import users_bp
from app.services.notifications import send_discord_notification
from app.validators import OneOnOneRequestSchema
@users_bp.route('/one-on-one', methods=['GET', 'POST'])
@login_required
def one_on_one():
"""One on One request page for players."""
if not isinstance(current_user, Player):
flash(_('Only players can request One on One sessions.'), 'danger')
return redirect(url_for('main.dashboard'))
org_teams = current_user.get_org_teams()
org_team = org_teams[0] if org_teams else None
# Reading org_team.coach_id directly told every player whose team lists
# its coaches through the many-to-many relationship — the newer of the
# two ways — that they had no coach, and closed the page to them.
# get_coaches() falls back to the legacy column when the list is empty.
team_coaches = org_team.get_coaches() if org_team else []
coach = team_coaches[0] if team_coaches else None
if not coach:
flash(_('You do not have a coach assigned to your team.'), 'info')
team_notes = []
if org_team:
team_notes = (
TeamNote.query.filter_by(org_team_id=org_team.id)
.order_by(TeamNote.created_at.desc())
.all()
)
personal_notes = (
PersonalNote.query.filter_by(player_id=current_user.id)
.order_by(PersonalNote.created_at.desc())
.all()
)
# Kept as model objects for the availability check below, and serialised
# separately for the page. They used to be the same list of strings,
# which is what made the check compare '9:00' with '10:00' as text.
availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all() if coach else []
coach_availability = [
{
'day_of_week': av.day_of_week,
'start_time': av.start_time.strftime('%H:%M'),
'end_time': av.end_time.strftime('%H:%M'),
}
for av in availabilities
]
if request.method == 'POST':
if not coach:
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
return redirect(url_for('users.one_on_one'))
try:
data = OneOnOneRequestSchema().load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.one_on_one'))
date_obj = data['date']
start_time = data['start_time']
end_time = data['end_time']
points = data['points'] or ''
# Compared as times, not as strings. The old code parsed the three
# form fields into objects and then compared the *original strings*
# against the serialised availability — which worked only because
# both sides happened to be zero-padded HH:MM.
is_available = any(
av.day_of_week == date_obj.weekday()
and av.start_time <= start_time
and av.end_time >= end_time
for av in availabilities
)
if not is_available:
flash(_("The requested time is not within the coach's availability."), 'danger')
return redirect(url_for('users.one_on_one'))
request_obj = OneOnOneRequest(
player_id=current_user.id,
coach_id=coach.id,
org_team_id=org_team.id if org_team else None,
date=date_obj,
start_time=start_time,
end_time=end_time,
points=points if points else None,
)
db.session.add(request_obj)
db.session.commit()
send_discord_notification(
player_name=current_user.full_name,
points=points,
date_str=date_obj.strftime('%Y-%m-%d'),
start_time_str=start_time.strftime('%H:%M'),
end_time_str=end_time.strftime('%H:%M'),
team_name=org_team.name if org_team else 'Unknown Team',
coach_name=coach.full_name,
coach_discord=coach.discord_username or '',
coach_discord_id=coach.discord_user_id or '',
request_id=request_obj.id,
)
flash(_('Your One on One request has been submitted!'), 'success')
return redirect(url_for('users.one_on_one'))
# Build list of upcoming dates that have coach availability
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}
dates = []
for i in range(14): # Next 14 days
d = today + td(days=i)
if d.weekday() in available_days:
dates.append(
{
'value': d.strftime('%Y-%m-%d'),
'day_of_week': d.weekday(),
'display': d.strftime('%B %d, %Y (%A)'),
}
)
# Player's own One on One request history
my_requests = (
OneOnOneRequest.query.filter_by(player_id=current_user.id)
.order_by(OneOnOneRequest.created_at.desc())
.all()
)
return render_template(
'pages/one_on_one.html',
org_team=org_team,
coach=coach,
team_notes=team_notes,
personal_notes=personal_notes,
coach_availability=coach_availability,
dates=dates,
my_requests=my_requests,
)
@users_bp.route('/one-on-one/<int:request_id>/accept', methods=['POST'])
@login_required
def accept_one_on_one(request_id):
"""Coach accepts a One on One request."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can accept One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
player = request_obj.player
request_obj.status = 'approved'
request_obj.responded_at = datetime.utcnow()
db.session.commit()
# Notify player via Discord (same message as if approved through Discord reactions)
if player and player.discord_user_id:
from app.discord_bot import send_one_on_one_response
send_one_on_one_response(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=current_user.full_name,
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
start_time=request_obj.start_time.strftime('%I:%M %p')
if request_obj.start_time
else 'TBD',
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
points=request_obj.points or 'No specific points provided',
approved=True,
)
flash(
_(
'One on One request from %(player)s has been approved!',
player=player.username if player else 'Unknown',
),
'success',
)
return redirect(url_for('users.notes_dashboard'))
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
@login_required
def reject_one_on_one(request_id):
"""Coach rejects a One on One request."""
if not isinstance(current_user, Coach):
flash(_('Only coaches can reject One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
if request_obj.coach_id != current_user.id:
flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
rejection_reason = request.form.get('rejection_reason', '').strip()
player = request_obj.player
request_obj.status = 'rejected'
request_obj.responded_at = datetime.utcnow()
if rejection_reason:
request_obj.coach_rejection_message = rejection_reason
db.session.commit()
# Notify player via Discord (same message as if rejected through Discord reactions)
if player and player.discord_user_id:
from app.discord_bot import send_one_on_one_response
send_one_on_one_response(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=current_user.full_name,
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
start_time=request_obj.start_time.strftime('%I:%M %p')
if request_obj.start_time
else 'TBD',
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
points=request_obj.points or 'No specific points provided',
approved=False,
refusal_note=rejection_reason or None,
)
flash(
_(
'One on One request from %(player)s has been rejected.',
player=player.username if player else 'Unknown',
),
'info',
)
return redirect(url_for('users.notes_dashboard'))
+130
View File
@@ -0,0 +1,130 @@
"""The signed-in user's own profile."""
from flask import flash, 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 app.extensions import db, hash_password
from app.logging_config import log_auth_event
from app.models import (
ESPORT_GAMES,
GAME_PLATFORMS,
Coach,
CoachAvailability,
Contract,
Player,
User,
)
from app.routes.users._shared import (
flash_validation_errors,
form_payload,
update_user_gamertags,
)
from app.routes.users.blueprint import users_bp
from app.validators import EditProfileSchema
@users_bp.route('/profile')
@login_required
def profile():
"""View the current user's profile."""
contracts = None
if isinstance(current_user, Player):
contracts = (
Contract.query.filter_by(
player_id=current_user.id,
)
.order_by(Contract.uploaded_at.desc())
.all()
)
existing_availability = None
if isinstance(current_user, Coach):
existing_availability = CoachAvailability.query.filter_by(
coach_id=current_user.id,
).all()
return render_template(
'pages/profile.html',
user=current_user,
contracts=contracts,
existing_availability=existing_availability,
)
@users_bp.route('/profile/edit', methods=['GET', 'POST'])
@login_required
def edit_profile():
"""Edit the current user's profile."""
if request.method == 'POST':
try:
validated = EditProfileSchema().load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
username = validated['username']
full_name = validated['full_name']
email = validated['email']
phone = validated.get('phone')
selected_games = validated.get('games', [])
discord_username = validated.get('discord_username')
league_os_profile = validated.get('league_os_profile')
if username != current_user.username and User.query.filter_by(username=username).first():
flash(_('Username already taken.'), 'danger')
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
if email != current_user.email and User.query.filter_by(email=email).first():
flash(_('Email already in use.'), 'danger')
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
current_user.username = username
current_user.full_name = full_name
current_user.email = email
current_user.phone = phone
current_user.games = ','.join(selected_games) if selected_games else None
current_user.discord_username = discord_username or None
current_user.league_os_profile = league_os_profile or None
update_user_gamertags(current_user, selected_games)
# Blank means "keep the current password"; anything else has already
# been checked against the policy by the schema.
password = validated.get('password')
if password:
current_user.password_hash = hash_password(password)
log_auth_event(
'account.password_changed', username=current_user.username, user_id=current_user.id
)
db.session.commit()
flash(_('Profile updated successfully!'), 'success')
return redirect(url_for('users.profile'))
return render_template(
'pages/edit_profile.html',
user=current_user,
esport_games=ESPORT_GAMES,
game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags(),
)
+1
View File
@@ -0,0 +1 @@
"""Business services: work that is neither a route nor a model."""
+127
View File
@@ -0,0 +1,127 @@
"""Outbound notifications.
Extracted from app/routes/users.py, where it sat between two route
definitions and pulled `requests`, `logging` and the Discord bot into a
module whose subject is HTTP handlers (ARCH-003).
Failures here are swallowed and logged on purpose: a notification that does
not reach Discord must not roll back the session it was announcing. That is
a property of the caller a Flask request whose work is already committed
not of the failure, which is why the breadth is argued for at each of the
two boundaries below rather than assumed (ARCH-008 / QUA-004).
The webhook branch is narrower than it was: `requests.RequestException`
covers every way an HTTP call can fail, and anything else coming out of it
is a defect worth seeing.
"""
import logging
import os
import requests
logger = logging.getLogger(__name__)
#: Either a webhook URL, or a bare Discord user id to DM instead.
DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL', '')
def send_discord_notification(
player_name,
points,
date_str,
start_time_str,
end_time_str,
team_name,
coach_name,
coach_discord,
coach_discord_id,
request_id=None,
):
"""Send a Discord notification for a One on One request.
Args:
player_name: Who is asking.
points: Free-text discussion points, possibly empty.
date_str, start_time_str, end_time_str: Already formatted for display.
team_name: The player's team, or None.
coach_name: Who is being asked.
coach_discord: The coach's Discord handle, for the webhook footer.
coach_discord_id: The coach's Discord snowflake, for a direct message.
request_id: OneOnOneRequest primary key, so reactions can find it back.
"""
if coach_discord_id:
try:
from app.discord_bot import send_one_on_one_dm
send_one_on_one_dm(
coach_name=coach_name,
coach_discord_id=coach_discord_id,
player_name=player_name,
team_name=team_name,
date_str=date_str,
start_time=start_time_str,
end_time=end_time_str,
points=points,
request_id=request_id,
)
except Exception: # noqa: BLE001 — the request that booked the meeting is already committed
logger.warning('Failed to hand the One on One DM to the bot', exc_info=True)
if not DISCORD_WEBHOOK_URL:
return
# A bare snowflake here means "DM this person instead", and only when the
# coach has no id of their own. Queueing it cannot raise (see _enqueue).
if DISCORD_WEBHOOK_URL.isdigit():
if not coach_discord_id:
from app.discord_bot import send_one_on_one_dm
send_one_on_one_dm(
coach_name=coach_name,
coach_discord_id=DISCORD_WEBHOOK_URL,
player_name=player_name,
team_name=team_name,
date_str=date_str,
start_time=start_time_str,
end_time=end_time_str,
points=points,
)
return
embed = {
"embeds": [
{
"title": "One on One Request",
"color": 3447003,
"fields": [
{"name": "Player", "value": player_name, "inline": True},
{
"name": "Team",
"value": team_name or "Unknown Team",
"inline": True,
},
{"name": "Date", "value": date_str, "inline": True},
{
"name": "Time",
"value": f"{start_time_str} - {end_time_str}",
"inline": True,
},
{
"name": "Discussion Points",
"value": points or "No specific points provided",
"inline": False,
},
],
"footer": {
"text": f"Coach: {coach_name}"
+ (f" (Discord: {coach_discord})" if coach_discord else ""),
},
}
],
}
try:
requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5)
except requests.RequestException as exc:
logger.warning('Failed to post the One on One webhook: %s', exc)
+96
View File
@@ -0,0 +1,96 @@
"""Announcing a scheduled match to the people who have to be there.
The same twenty lines appeared three times matches.create_match,
matches.edit_match and team_matches.create_match each formatting the date
and time itself, then walking two parallel lists in lockstep to pair a
player with the participant row that a Discord reaction has to find again
(ARCH-003).
Three copies meant three chances to drift, and they had:
create_match read the times from local variables it had just parsed, while
edit_match re-derived them from the saved row and substituted the start
time for a missing end time. The rule kept here is the more careful of the
two.
"""
import logging
from app.discord_bot import send_schedule_notification
logger = logging.getLogger(__name__)
#: What the bot shows when a match has no usable time.
TIME_UNKNOWN = 'TBD'
def format_event_time(start_time, end_time):
"""Render a match's time range the way the Discord message expects.
Args:
start_time: A time, or None.
end_time: A time, or None. Falls back to start_time, so a match with
only a start still announces something useful.
Returns:
str: 'HH:MM AM - HH:MM PM', or TIME_UNKNOWN.
"""
if not start_time:
return TIME_UNKNOWN
finish = end_time or start_time
return f'{start_time.strftime("%I:%M %p")} - {finish.strftime("%I:%M %p")}'
def zip_participants(player_ids, participant_ids):
"""Pair each player with the participant row that was created for them.
The routes build these as two parallel lists, appended in step. Pairing
them by index is what the original code did, and it is only correct as
long as they stay in step hence one place to look at rather than
three. A short participant list yields None, which
:func:`notify_participants` turns into the fallback reference.
Args:
player_ids: Player primary keys, in creation order.
participant_ids: Participant row ids, in the same order.
Yields:
tuple[int, int | None]: (player_id, participant_id).
"""
for index, player_id in enumerate(player_ids):
yield player_id, participant_ids[index] if index < len(participant_ids) else None
def notify_participants(*, title, date, start_time, end_time, participants, fallback_id):
"""Tell each participant that a match has been scheduled or changed.
Args:
title: Match title, shown in the message.
date: The match date.
start_time: Start time, or None.
end_time: End time, or None.
participants: Iterable of (player_id, participant_id) pairs.
participant_id is what a Discord reaction resolves back to, so
attendance lands on the right row.
fallback_id: Reference to use when a participant row has no id
the match's own, which the bot can still act on.
Returns:
int: How many notifications were handed to the bot.
"""
event_date = date.strftime('%Y-%m-%d')
event_time = format_event_time(start_time, end_time)
sent = 0
for player_id, participant_id in participants:
if not player_id:
continue
send_schedule_notification(
user_id=player_id,
event_type='match',
event_title=title,
event_date=event_date,
event_time=event_time,
reference_id=participant_id or fallback_id,
)
sent += 1
return sent
@@ -96,6 +96,7 @@ a:hover { color: var(--primary-dark); }
} }
.logo i { color: var(--primary); font-size: 1.6rem; } .logo i { color: var(--primary); font-size: 1.6rem; }
.logo-img { height: 40px; width: auto; }
.user-badge { .user-badge {
display: flex; display: flex;
@@ -138,7 +139,10 @@ a:hover { color: var(--primary-dark); }
padding: 10px 0; padding: 10px 0;
} }
.nav-links li a { /* Logging out is a POST, so its nav entry is a button inside a form
rather than a link. It has to read as one of the entries above it. */
.nav-links li a,
.nav-links li .nav-form button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -148,7 +152,17 @@ a:hover { color: var(--primary-dark); }
font-size: 0.9rem; font-size: 0.9rem;
} }
.nav-links li a:hover, .nav-links li a.active { .nav-links li .nav-form button {
width: 100%;
background: none;
border: 0;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.nav-links li a:hover, .nav-links li a.active,
.nav-links li .nav-form button:hover {
background: rgba(255,255,255,0.08); background: rgba(255,255,255,0.08);
color: white; color: white;
} }
@@ -158,7 +172,8 @@ a:hover { color: var(--primary-dark); }
padding-left: 17px; padding-left: 17px;
} }
.nav-links li a i { width: 20px; text-align: center; font-size: 1.1rem; } .nav-links li a i,
.nav-links li .nav-form button i { width: 20px; text-align: center; font-size: 1.1rem; }
.nav-divider { .nav-divider {
height: 1px; height: 1px;
@@ -607,6 +622,12 @@ a:hover { color: var(--primary-dark); }
margin-bottom: 12px; margin-bottom: 12px;
} }
.auth-logo-img {
height: 80px;
width: auto;
margin-bottom: 12px;
}
.auth-header h2 { .auth-header h2 {
font-size: 1.6rem; font-size: 1.6rem;
font-weight: 700; font-weight: 700;
@@ -1231,9 +1252,9 @@ a:hover { color: var(--primary-dark); }
} }
.merged-disponibility-time-block.selected { .merged-disponibility-time-block.selected {
background: var(--success); background: #6366f1 !important;
color: white; color: white !important;
border-color: var(--success); border-color: #4f46e5 !important;
} }
.merged-disponibility-time-block.high-availability { .merged-disponibility-time-block.high-availability {
@@ -1266,10 +1287,10 @@ a:hover { color: var(--primary-dark); }
/* Then specific states override the generic rule */ /* Then specific states override the generic rule */
[data-theme="dark"] .merged-disponibility-time-block.selected { [data-theme="dark"] .merged-disponibility-time-block.selected {
background: var(--success) !important; background: #6366f1 !important;
color: white !important; color: white !important;
border-color: var(--success) !important; border-color: #818cf8 !important;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.5); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.5);
} }
[data-theme="dark"] .merged-disponibility-time-block.high-availability { [data-theme="dark"] .merged-disponibility-time-block.high-availability {
@@ -1948,3 +1969,71 @@ a:hover { color: var(--primary-dark); }
[data-theme="dark"] .error-container p { [data-theme="dark"] .error-container p {
color: var(--text-secondary); color: var(--text-secondary);
} }
/* =========================================================================
Language switcher
========================================================================= */
.nav-language {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.nav-language .lang-link {
color: var(--text-secondary);
text-decoration: none;
padding: 0;
}
.nav-language .lang-link:hover {
color: var(--primary);
text-decoration: underline;
}
.nav-language .lang-current {
font-weight: 600;
color: var(--text-primary);
}
.nav-language .lang-separator {
opacity: 0.4;
}
.auth-language {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 18px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.auth-language .lang-link { color: var(--text-secondary); text-decoration: none; }
.auth-language .lang-link:hover { color: var(--primary); text-decoration: underline; }
.auth-language .lang-current { font-weight: 600; color: var(--text-primary); }
.auth-language .lang-separator { opacity: 0.4; }
/* Visually hidden, still announced by screen readers. */
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+158
View File
@@ -437,3 +437,161 @@ document.addEventListener('DOMContentLoaded', function() {
}, 5000); }, 5000);
}); });
}); });
/* =========================================================================
Declarative behaviours replacing inline event handlers
=========================================================================
A Content Security Policy without 'unsafe-inline' blocks `onclick="..."`
attributes, and a nonce does not help: nonces apply to <script> elements,
never to event handler attributes. Dropping 'unsafe-inline' therefore
requires removing every one of them first.
Rather than one listener per widget, behaviours are declared in the
markup with a data-action attribute and dispatched from a single
delegated listener. New markup gets the behaviour for free, and nothing
has to be re-bound after content is replaced dynamically.
<button data-action="toggle-sidebar">
<button data-action="dismiss-alert">
<div data-action="hide-modal" data-modal-id="confirmDelete">
Migration status is tracked by tests/test_csp.py.
========================================================================= */
const DATA_ACTIONS = {
'toggle-sidebar': function () {
toggleSidebar();
},
'toggle-dark-mode': function () {
toggleDarkMode();
},
'dismiss-alert': function (element) {
const alert = element.closest('.alert');
if (alert) {
alert.remove();
}
},
'hide-modal': function (element) {
const id = element.getAttribute('data-modal-id');
if (id && typeof hideModal === 'function') {
hideModal(id);
}
},
'history-back': function (element, event) {
event.preventDefault();
history.back();
},
// Removes the nearest ancestor matching data-remove, or the parent.
'remove-element': function (element) {
const selector = element.getAttribute('data-remove');
const target = selector ? element.closest(selector) : element.parentElement;
if (target) {
target.remove();
}
},
};
/**
* Register behaviours defined by a single page.
*
* Page-local functions live in that page's script block, so they cannot be
* listed in DATA_ACTIONS above. Each page declares its own:
*
* registerActions({ 'clear-availability': clearAllAvailability });
*
* @param {Object} map - action name to handler(element, event).
*/
function registerActions(map) {
Object.assign(DATA_ACTIONS, map);
}
function dispatchAction(attribute, event) {
const trigger = event.target.closest('[' + attribute + ']');
if (!trigger) {
return;
}
const handler = DATA_ACTIONS[trigger.getAttribute(attribute)];
if (handler) {
handler(trigger, event);
}
}
document.addEventListener('click', function (event) {
dispatchAction('data-action', event);
});
// Separate attribute rather than one shared with click: a <select> would
// otherwise fire its handler on the click that opens it.
document.addEventListener('change', function (event) {
dispatchAction('data-change', event);
});
/**
* Confirmation before a destructive submit.
*
* <form data-confirm="Delete this match?">
*
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
* markup where it can be translated.
*/
document.addEventListener('submit', function (event) {
const form = event.target.closest('[data-confirm]');
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
event.preventDefault();
}
});
/**
* Live value display next to a range input.
*
* <input type="range" data-mirror>
* <span>5</span>
*
* Replaces oninput="this.nextElementSibling.textContent = this.value",
* which the evaluation form repeated on all nine score sliders.
* data-mirror may name a selector; empty means the next sibling.
*/
document.addEventListener('input', function (event) {
const input = event.target.closest('[data-mirror]');
if (!input) {
return;
}
const selector = input.getAttribute('data-mirror');
const target = selector
? document.querySelector(selector)
: input.nextElementSibling;
if (target) {
target.textContent = input.value;
}
});
/**
* Submit the surrounding form when a control changes.
*
* <select data-submit-on-change>
*
* Replaces onchange="this.form.submit()".
*/
document.addEventListener('change', function (event) {
const control = event.target.closest('[data-submit-on-change]');
if (control && control.form) {
control.form.submit();
}
});
/**
* Navigate on selection.
*
* <select data-navigate="/team-matches/{value}/create">
*
* {value} is replaced by the chosen option, URL-encoded. An empty
* selection navigates nowhere.
*/
document.addEventListener('change', function (event) {
const select = event.target.closest('[data-navigate]');
if (!select || !select.value) {
return;
}
window.location.href = select.getAttribute('data-navigate')
.replace('{value}', encodeURIComponent(select.value));
});
+149
View File
@@ -0,0 +1,149 @@
"""Where the application's files live, and how the database refers to them.
Three roots: the project itself, the document store, and the log directory.
They are here together because they are the same defect three times over
(OBS-006) `os.path.join(os.getcwd(), )`, evaluated at import or upload
time, so every one of them moved with whatever directory the process was
started from. The document store was fixed first, in wave G, because it was
the one that also blocked OPS-011; the other two were left behind, which is
the repeated lesson of this project: a faulty pattern corrected in one layer
stays in the others.
Contracts were stored at `os.path.join(os.getcwd(), 'documents', )`,
evaluated at upload time, and the resulting absolute path was written into
`Contract.file_path`. The storage root therefore moved with whatever
directory the process happened to be started from. Two consequences:
- one latent: start the server from elsewhere and new contracts land in a
new tree while the old ones become unreadable with the database still
saying they are there, so the failure surfaces as a 500 on download
rather than as anything a person could act on;
- one blocking: it rules out a release-directory deployment (OPS-011)
outright. Every stored path would point inside a release that is about
to be replaced, so the first switch would take every contract ever
uploaded with it.
New rows keep a path *relative* to the document root. Old rows keep their
absolute path and are returned untouched, so this change needs no data
migration and can ship before Alembic does (DB-002).
"""
import os
#: Environment override for the document root. What a release-directory
#: deployment sets, to a path outside the releases — alongside them, not
#: inside whichever one is current.
DOCUMENTS_ROOT_ENV = 'DOCUMENTS_ROOT'
#: Environment override for the log directory. Same reasoning: a release
#: directory that carries its own logs loses them at the next switch.
LOG_DIR_ENV = 'LOG_DIR'
#: Environment override for the backup directory.
BACKUP_DIR_ENV = 'BACKUP_DIR'
#: Sub-directory holding uploaded contracts, under the document root.
CONTRACTS_DIR = 'contrats signés'
def project_root():
"""Absolute path of the project, derived from this file's location.
The anchor every other root falls back to. Not `os.getcwd()`: the
process is started by Waitress under Pterodactyl, by pytest, by a task
scheduler and by a person in a shell, and only one of those four is
reliably in the project directory.
"""
package_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.dirname(package_dir)
def documents_root():
"""Absolute path of the document store.
Falls back to `documents/` beside this package the project root
wherever it is installed, rather than wherever the process was launched.
"""
return _rooted(DOCUMENTS_ROOT_ENV, 'documents')
def logs_root():
"""Absolute path of the log directory.
Was `os.path.join(os.getcwd(), 'logs')`. Starting the server from
another directory sent the logs somewhere new without a word, which is
the worst possible failure mode for the one file you go and read when
something else has gone wrong.
"""
return _rooted(LOG_DIR_ENV, 'logs')
def backups_root():
"""Absolute path of the backup directory."""
return _rooted(BACKUP_DIR_ENV, 'backups')
def _rooted(env_name, default_name):
"""The configured path, or `default_name` under the project root."""
configured = os.getenv(env_name)
if configured:
return os.path.abspath(configured)
return os.path.join(project_root(), default_name)
def discard_documents(stored_paths):
"""Remove these documents from disk. Returns how many went (DATA-012).
`delete_user` removed the Contract rows and left the PDFs. Signed,
named contracts therefore stayed on the server after the account was
deleted, with nothing in the database pointing at them invisible to
the application, unmanageable through it, and still personal data.
Call this **after** the commit that removed the rows, never before: a
failure between the two should leave a file with no row (recoverable,
and what the previous behaviour produced anyway) rather than a row with
no file (a download that 500s for ever).
A path that cannot be removed is logged and skipped. Nothing here should
be able to abort the deletion of an account.
"""
import logging
logger = logging.getLogger(__name__)
removed = 0
for stored_path in stored_paths:
if not stored_path:
continue
target = document_path(stored_path)
try:
os.remove(target)
removed += 1
except FileNotFoundError:
# Already gone. Two contracts sharing a stem, or a previous
# attempt: not a problem, and not worth an error line.
logger.info('Document already absent: %s', target)
except OSError as exc:
logger.error(
'Could not remove %s (%s). It is now an orphan: no database row '
'refers to it, so nothing in the application will ever offer to '
'delete it again.',
target,
exc,
)
return removed
def document_path(stored_path):
"""Absolute path of a document, from what the database holds.
Args:
stored_path: The value of Contract.file_path or signed_file_path.
Relative for rows written since this module existed, absolute
for the ones written before.
Returns:
str: An absolute path.
"""
if os.path.isabs(stored_path):
return stored_path
return os.path.join(documents_root(), stored_path)
+1
View File
@@ -0,0 +1 @@
# supporting scripts package
+377
View File
@@ -0,0 +1,377 @@
"""Database and document backup for the Team Tryouts application.
Dumps the PostgreSQL database with pg_dump and archives the uploaded
contract documents. Designed to be run from a scheduled task (Windows Task
Scheduler) or a cron job.
Usage:
python app/supporting_scripts/backup.py
python app/supporting_scripts/backup.py --verify-only <archive>
Configuration via environment variables:
DATABASE_URL PostgreSQL connection string (required)
BACKUP_DIR Where to store backups (default: ./backups)
BACKUP_RETENTION_DAYS How long to keep them (default: 30)
PG_DUMP Path to pg_dump if not on PATH
PG_RESTORE Path to pg_restore if not on PATH
A note on what this file used to be
-----------------------------------
The previous version targeted **SQLite**: it imported sqlite3, read
DATABASE_PATH defaulting to instance/team_tryouts.db, and used the sqlite3
backup API. Production runs on PostgreSQL, so the file never existed, the
script printed "[WARNING] Database not found... Skipping database backup"
and because main() only tracked the verification result still exited 0.
It reported success while backing up nothing at all. Any scheduled task
watching the exit code saw green.
Restoring is documented in docs/restauration-base.md. A backup that has
never been restored is not a backup.
"""
import argparse
import os
import shutil
import subprocess
import sys
from datetime import datetime, timedelta
from urllib.parse import unquote, urlparse
# Run as `python app/supporting_scripts/backup.py`, sys.path[0] is this
# script's directory, so the application package is not importable. It has
# to be — see DOCUMENTS_DIR below.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from app.storage import backups_root, documents_root # noqa: E402 — needs the path above
# Configuration
BACKUP_DIR = backups_root()
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
PG_DUMP = os.getenv('PG_DUMP', 'pg_dump')
PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore')
# There is deliberately no DOCUMENTS_DIR constant any more. It held
# `os.path.join(os.getcwd(), 'documents')`, which had stopped being true:
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
# keep uploads outside the releases, and docs/deployment.md now tells the
# operator to set it — at which point this script archived a directory the
# application had never written to. It does not fail on a missing directory
# either; it prints "No documents directory found", skips, and exits 0.
#
# So the more correctly an operator followed the deployment documentation,
# the more certainly their contract backups were empty (OBS-006).
#
# backup_documents() now asks app.storage, at call time, the same question
# the upload path asks. One source of truth, and one that a test can move.
class BackupError(Exception):
"""Raised when a backup step fails in a way that must stop the run."""
# ---------------------------------------------------------------------------
# Connection handling
# ---------------------------------------------------------------------------
def parse_database_url(url):
"""Split a SQLAlchemy/PostgreSQL URL into pg_dump connection settings.
Accepts the dialect suffixes SQLAlchemy uses (postgresql+psycopg://),
which pg_dump does not understand.
Args:
url: The connection string.
Returns:
dict: host, port, dbname, user, password.
Raises:
BackupError: If the URL is missing or is not a PostgreSQL one.
"""
if not url:
raise BackupError('DATABASE_URL is not set.')
parsed = urlparse(url)
scheme = parsed.scheme.split('+')[0]
if scheme not in ('postgresql', 'postgres'):
raise BackupError(
f'DATABASE_URL is not a PostgreSQL connection string (scheme: {scheme!r}). '
'This script only backs up PostgreSQL.'
)
dbname = (parsed.path or '').lstrip('/')
if not dbname:
raise BackupError('DATABASE_URL does not name a database.')
return {
'host': parsed.hostname or 'localhost',
'port': str(parsed.port or 5432),
'dbname': dbname,
'user': unquote(parsed.username) if parsed.username else '',
'password': unquote(parsed.password) if parsed.password else '',
}
def describe_target(conn):
"""Human-readable target, deliberately without the password."""
user = f'{conn["user"]}@' if conn['user'] else ''
return f'{user}{conn["host"]}:{conn["port"]}/{conn["dbname"]}'
def build_dump_command(conn, output_path):
"""Assemble the pg_dump invocation.
--format=custom is compressed and lets pg_restore rebuild selectively;
plain SQL would be larger and all-or-nothing.
The password is never placed on the command line it would be visible
to anyone able to list processes. It travels through PGPASSWORD instead,
which is what pg_dump documents for non-interactive use.
"""
return [
PG_DUMP,
'--host',
conn['host'],
'--port',
conn['port'],
'--username',
conn['user'],
'--dbname',
conn['dbname'],
'--format=custom',
'--no-owner',
'--no-privileges',
'--file',
output_path,
]
def dump_environment(conn):
"""Environment for pg_dump/pg_restore, carrying the password out of argv."""
env = os.environ.copy()
if conn['password']:
env['PGPASSWORD'] = conn['password']
return env
# ---------------------------------------------------------------------------
# Backup steps
# ---------------------------------------------------------------------------
def create_backup_dir():
"""Create the backup directory if it doesn't exist."""
os.makedirs(BACKUP_DIR, exist_ok=True)
def backup_database(conn):
"""Dump the PostgreSQL database.
Returns:
str: Path to the created archive.
Raises:
BackupError: If pg_dump is missing, fails, or produces nothing.
"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = os.path.join(BACKUP_DIR, f'db_backup_{timestamp}.dump')
print(f'[INFO] Dumping {describe_target(conn)}')
try:
result = subprocess.run(
build_dump_command(conn, backup_path),
env=dump_environment(conn),
capture_output=True,
text=True,
timeout=900,
)
except FileNotFoundError as err:
raise BackupError(
f'{PG_DUMP} not found. Install the PostgreSQL client tools, or set '
'PG_DUMP to its full path.'
) from err
except subprocess.TimeoutExpired as err:
raise BackupError('pg_dump timed out after 15 minutes.') from err
if result.returncode != 0:
raise BackupError(f'pg_dump failed: {result.stderr.strip()}')
if not os.path.exists(backup_path) or os.path.getsize(backup_path) == 0:
raise BackupError('pg_dump reported success but produced an empty file.')
size_mb = os.path.getsize(backup_path) / (1024 * 1024)
print(f'[OK] Database backed up to: {backup_path} ({size_mb:.1f} MB)')
return backup_path
def verify_backup(backup_path):
"""Check that the archive is readable and actually contains tables.
pg_restore --list parses the whole archive without touching any
database. A dump that cannot be listed cannot be restored, and an
archive holding no table would mean the dump ran against the wrong
target both are silent failures worth catching here rather than
during an incident.
Args:
backup_path: Path to the archive to verify.
Returns:
bool: True if the archive looks restorable.
"""
if not backup_path or not os.path.exists(backup_path):
print('[ERROR] Nothing to verify.')
return False
try:
result = subprocess.run(
[PG_RESTORE, '--list', backup_path],
capture_output=True,
text=True,
timeout=300,
)
except FileNotFoundError:
print(f'[WARNING] {PG_RESTORE} not found: archive left unverified.')
return False
except subprocess.TimeoutExpired:
print('[ERROR] pg_restore --list timed out.')
return False
if result.returncode != 0:
print(f'[ERROR] Archive is not readable: {result.stderr.strip()}')
return False
table_count = sum(1 for line in result.stdout.splitlines() if ' TABLE DATA ' in line)
if table_count == 0:
print('[ERROR] Archive contains no table data.')
return False
print(f'[OK] Archive verified: {table_count} table(s) present.')
return True
def backup_documents():
"""Archive the uploaded contract documents directory.
The directory is resolved through `app.storage.documents_root()` the
same function the upload path uses so that setting DOCUMENTS_ROOT
moves both together. Resolved here rather than at import, so that what
is backed up depends on the environment the run has, not on the one the
module happened to be imported with.
Returns:
str: Path to the created archive, or None if there is nothing to
archive. Signed contracts live only on disk, so losing this
directory loses the documents themselves.
"""
documents_dir = documents_root()
if not os.path.exists(documents_dir):
# Says where it looked. The previous message named no path, so an
# operator who had moved the documents read it as "there are no
# documents" rather than "I am looking in the wrong place".
print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.')
return None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
try:
shutil.make_archive(archive_basename, 'zip', documents_dir)
except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump
# This runs after the database dump has already succeeded. Letting
# anything through here would abort the script with a traceback and
# take the one part that worked down with it. Reported to stdout, in
# the format the rest of this script uses; it has no logger.
print(f'[ERROR] Document backup failed: {exc}')
return None
zip_path = f'{archive_basename}.zip'
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
return zip_path
def cleanup_old_backups():
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
if not os.path.exists(BACKUP_DIR):
return
cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS)
removed_count = 0
for filename in os.listdir(BACKUP_DIR):
file_path = os.path.join(BACKUP_DIR, filename)
if not os.path.isfile(file_path):
continue
if datetime.fromtimestamp(os.path.getmtime(file_path)) >= cutoff:
continue
try:
os.remove(file_path)
removed_count += 1
print(f'[CLEANUP] Removed old backup: {filename}')
except OSError as exc:
print(f'[WARNING] Could not remove {filename}: {exc}')
if removed_count:
print(f'[CLEANUP] Removed {removed_count} old backup(s).')
else:
print('[CLEANUP] No old backups to remove.')
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main(argv=None):
"""Run the full backup process.
Returns:
int: 0 when the database was dumped AND verified, 1 otherwise. The
previous version returned 0 even when it had backed up nothing.
"""
parser = argparse.ArgumentParser(description='Team Tryouts backup')
parser.add_argument(
'--verify-only', metavar='ARCHIVE', help='Verify an existing archive and exit'
)
args = parser.parse_args(argv)
if args.verify_only:
return 0 if verify_backup(args.verify_only) else 1
print('=== Team Tryouts Backup ===')
print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
print(f'Backup directory: {BACKUP_DIR}')
# Printed because it is the value that was wrong for months without
# anyone being able to see it from the output.
print(f'Document source: {documents_root()}')
print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
print()
try:
conn = parse_database_url(os.getenv('DATABASE_URL'))
create_backup_dir()
backup_path = backup_database(conn)
except BackupError as exc:
print(f'[ERROR] {exc}')
print('\n=== Backup FAILED — no database backup was produced ===')
return 1
verified = verify_backup(backup_path)
backup_documents()
cleanup_old_backups()
print()
if verified:
print('=== Backup completed successfully ===')
return 0
print('=== Backup FAILED verification — do not rely on this archive ===')
return 1
if __name__ == '__main__':
sys.exit(main())
+443
View File
@@ -0,0 +1,443 @@
"""Compare a live database against the models (DB-001).
Why this exists
---------------
`db.create_all()` creates missing tables and never ALTERs an existing one. A
column added to a model months ago is therefore simply absent from any
database that already had the table, and nothing says so: the application
starts, and the first query touching that column fails at runtime. The audit
called the accumulated result "les dérives" and could not measure it, because
measuring it needs the production database.
This script measures it. It is **read-only** it opens a connection, reads
the catalogue, prints a report and exits. It issues no DDL and no DML, and
takes no locks beyond what reading `information_schema` takes.
It is the prerequisite for everything in the DB wave: `DB-002` asks for an
initial Alembic migration describing the **real** schema rather than the
models', and this is what tells you what the real schema is.
Usage
-----
# Against whatever DATABASE_URL points at
python app/supporting_scripts/schema_report.py
# Against a restored copy, which is the safe way to do it first
python app/supporting_scripts/schema_report.py \
--url postgresql://user:pass@host:5432/restored_copy
# Also look for the seeded admin/password account (SEC-003)
python app/supporting_scripts/schema_report.py --check-seed-accounts
# Find Discord identities that must be reconciled before UNIQUE (SEC-012)
python app/supporting_scripts/schema_report.py --check-discord-identities
Exit codes
----------
0 the live schema matches the models
1 drift or requested data risk found the report says what
2 could not connect or read the catalogue
Reading the output
------------------
Findings are grouped by what they cost you:
BLOCKING the application will fail at runtime a table or column the
models use and the database does not have.
RISK the database has something the models do not describe. Harmless
to the running application, but an Alembic autogenerate would
propose to DROP it, which is how a corrective migration deletes
a column somebody still needed.
DIFFERENCE type, nullability, default or constraint disagreements. Each one
needs a human: some are dialect spelling, some are real.
"""
import argparse
import os
import sys
# Importable as a script from the project root, like the other supporting
# scripts: `python app/supporting_scripts/schema_report.py`.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from sqlalchemy import create_engine, inspect # noqa: E402
from sqlalchemy.exc import SQLAlchemyError # noqa: E402
BLOCKING = 'BLOCKING'
RISK = 'RISK'
DIFFERENCE = 'DIFFERENCE'
class Finding:
"""One disagreement between the models and the live database."""
def __init__(self, severity, table, detail, consequence=''):
self.severity = severity
self.table = table
self.detail = detail
self.consequence = consequence
def __str__(self):
line = f' [{self.severity:10}] {self.table}: {self.detail}'
if self.consequence:
line += f'\n{self.consequence}'
return line
def __repr__(self): # pragma: no cover — debugging aid
return f'<Finding {self.severity} {self.table} {self.detail}>'
def model_metadata():
"""The schema the models describe.
Imports app.models for its side effect: importing the modules is what
registers every table on the shared metadata.
"""
from app.extensions import db
from app.models import User # noqa: F401 — registers the whole model package
return db.metadata
def _type_of(column_type, dialect):
"""A type as this dialect spells it, so the two sides are comparable.
Comparing `String(200)` with `VARCHAR(200)` as strings would report every
column as different. Compiling both against the same dialect makes the
comparison mean something.
"""
try:
return column_type.compile(dialect=dialect)
except Exception: # noqa: BLE001 — an uncompilable type is still reportable
return str(column_type)
def compare_tables(metadata, inspector):
"""Tables the models expect against tables the database has."""
findings = []
model_tables = set(metadata.tables)
live_tables = set(inspector.get_table_names())
for name in sorted(model_tables - live_tables):
findings.append(
Finding(
BLOCKING,
name,
'table is missing from the database',
'every query against this model fails. create_all() would '
'create it — which is why the absence can survive unnoticed '
'on a machine where AUTO_CREATE_TABLES is on.',
)
)
for name in sorted(live_tables - model_tables):
findings.append(
Finding(
RISK,
name,
'table exists in the database and in no model',
'an Alembic autogenerate would propose to DROP it. Decide '
'before running one: it may be a leftover, or it may be the '
'only copy of something.',
)
)
return findings, sorted(model_tables & live_tables)
def compare_columns(metadata, inspector, table_name, dialect):
"""Column-by-column, for one table."""
findings = []
model_columns = {c.name: c for c in metadata.tables[table_name].columns}
live_columns = {c['name']: c for c in inspector.get_columns(table_name)}
for name in sorted(set(model_columns) - set(live_columns)):
column = model_columns[name]
findings.append(
Finding(
BLOCKING,
table_name,
f'column "{name}" is in the model and not in the database',
'this is exactly what create_all() cannot fix: it never '
'ALTERs. Any query selecting or writing this column fails.'
+ (
''
if column.nullable
else ' The column is NOT NULL, so the '
'corrective migration needs a default or a backfill.'
),
)
)
for name in sorted(set(live_columns) - set(model_columns)):
findings.append(
Finding(
RISK,
table_name,
f'column "{name}" is in the database and not in any model',
'an autogenerated migration would propose to DROP it, taking '
'its data. Check whether something outside the application '
'reads it before agreeing.',
)
)
for name in sorted(set(model_columns) & set(live_columns)):
model_column, live_column = model_columns[name], live_columns[name]
model_type = _type_of(model_column.type, dialect)
live_type = _type_of(live_column['type'], dialect)
if model_type != live_type:
findings.append(
Finding(
DIFFERENCE,
table_name,
f'column "{name}" type: model says {model_type}, database says {live_type}',
'a narrower column in the database silently truncates or '
'rejects; a wider one is usually harmless.',
)
)
if bool(model_column.nullable) != bool(live_column.get('nullable', True)):
findings.append(
Finding(
DIFFERENCE,
table_name,
f'column "{name}" nullability: model says '
f'{"NULL" if model_column.nullable else "NOT NULL"}, database says '
f'{"NULL" if live_column.get("nullable", True) else "NOT NULL"}',
'a NOT NULL the database does not enforce is a constraint '
'the application only believes it has.',
)
)
return findings
def compare_constraints(metadata, inspector, table_name):
"""Unique constraints, indexes and foreign keys.
Named constraints are compared by the columns they cover rather than by
name: the same rule declared under two names is the same rule, and
reporting it as a difference would bury the ones that matter.
"""
findings = []
table = metadata.tables[table_name]
def column_sets(entries, key):
return {tuple(sorted(entry[key] or [])) for entry in entries}
model_unique = {
tuple(sorted(c.name for c in constraint.columns))
for constraint in table.constraints
if constraint.__class__.__name__ == 'UniqueConstraint'
}
live_unique = column_sets(inspector.get_unique_constraints(table_name), 'column_names')
for columns in sorted(model_unique - live_unique):
findings.append(
Finding(
DIFFERENCE,
table_name,
f'unique constraint on {list(columns)} is declared and absent from the database',
'the application believes duplicates are impossible here. '
'They are not, and two concurrent requests will prove it.',
)
)
model_fks = {
tuple(sorted(fk.parent.name for fk in constraint.elements))
for constraint in table.foreign_key_constraints
}
live_fks = column_sets(inspector.get_foreign_keys(table_name), 'constrained_columns')
for columns in sorted(model_fks - live_fks):
findings.append(
Finding(
DIFFERENCE,
table_name,
f'foreign key on {list(columns)} is declared and absent from the database',
'orphan rows are possible, and ON DELETE behaviour is not '
'being enforced by the database at all.',
)
)
model_indexes = {tuple(sorted(c.name for c in index.columns)) for index in table.indexes}
live_indexes = column_sets(inspector.get_indexes(table_name), 'column_names')
for columns in sorted(model_indexes - live_indexes):
findings.append(
Finding(
DIFFERENCE,
table_name,
f'index on {list(columns)} is declared and absent from the database',
'correctness is unaffected; the queries that rely on it are '
'doing sequential scans.',
)
)
return findings
def collect_findings(engine):
"""Every disagreement between the models and this database."""
metadata = model_metadata()
inspector = inspect(engine)
findings, shared_tables = compare_tables(metadata, inspector)
for table_name in shared_tables:
findings.extend(compare_columns(metadata, inspector, table_name, engine.dialect))
findings.extend(compare_constraints(metadata, inspector, table_name))
return findings
def find_seed_accounts(engine):
"""Accounts matching the credentials clear_db.py used to seed (SEC-003).
The script was removed from the deployment, but it had already been run:
the audit could not tell whether an `admin` account with the password
`password` still exists in production, and that question cannot be
answered from the repository.
Returns:
list[tuple]: (username, role, whether the known password matches).
"""
from sqlalchemy import text
from app.extensions import check_password
with engine.connect() as connection:
rows = connection.execute(
text('SELECT username, role, password_hash FROM users WHERE username = :name'),
{'name': 'admin'},
).fetchall()
results = []
for username, role, password_hash in rows:
try:
matches = check_password(password_hash, 'password')
except Exception: # noqa: BLE001 — an unreadable hash is not a match
matches = False
results.append((username, role, matches))
return results
def find_duplicate_discord_identities(engine):
"""Discord snowflakes claimed by more than one account (SEC-012).
New links are now refused in application code, but existing production
rows predate that guard. These groups must be reconciled before Alembic
can add the database-level UNIQUE constraint.
Returns:
list[tuple]: (discord_user_id, comma-separated usernames, count).
"""
from sqlalchemy import text
with engine.connect() as connection:
rows = connection.execute(
text(
'SELECT discord_user_id, COUNT(*) AS account_count '
'FROM users '
"WHERE discord_user_id IS NOT NULL AND discord_user_id <> '' "
'GROUP BY discord_user_id HAVING COUNT(*) > 1 '
'ORDER BY discord_user_id'
)
).fetchall()
duplicates = []
for discord_user_id, account_count in rows:
usernames = connection.execute(
text(
'SELECT username FROM users '
'WHERE discord_user_id = :discord_user_id ORDER BY username'
),
{'discord_user_id': discord_user_id},
).scalars()
duplicates.append((discord_user_id, ', '.join(usernames), account_count))
return duplicates
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
parser.add_argument(
'--url',
default=os.getenv('DATABASE_URL'),
help='Database URL. Defaults to DATABASE_URL. Point it at a restored copy the first time.',
)
parser.add_argument(
'--check-seed-accounts',
action='store_true',
help='Also look for the admin/password account seeded by clear_db.py (SEC-003).',
)
parser.add_argument(
'--check-discord-identities',
action='store_true',
help='Find duplicate Discord IDs that block the SEC-012 UNIQUE constraint.',
)
args = parser.parse_args(argv)
if not args.url:
print('No database URL. Pass --url or set DATABASE_URL.', file=sys.stderr)
return 2
from app.app import normalise_database_url
try:
engine = create_engine(normalise_database_url(args.url))
findings = collect_findings(engine)
except SQLAlchemyError as exc:
print(f'Could not read the schema: {exc}', file=sys.stderr)
return 2
print('=' * 78)
print('Schema report — models vs live database (DB-001)')
print('=' * 78)
if not findings:
print('\nNo drift. The live schema matches the models.')
for severity in (BLOCKING, RISK, DIFFERENCE):
group = [f for f in findings if f.severity == severity]
if not group:
continue
print(f'\n{severity}{len(group)} finding(s)')
for finding in group:
print(finding)
if args.check_seed_accounts:
print('\n' + '=' * 78)
print('Seeded accounts (SEC-003)')
print('=' * 78)
try:
accounts = find_seed_accounts(engine)
except SQLAlchemyError as exc:
print(f'Could not check: {exc}')
else:
if not accounts:
print('No account named "admin".')
for username, role, matches in accounts:
verdict = (
'PASSWORD IS STILL "password" — change it now'
if matches
else 'password has been changed'
)
print(f' {username} ({role}): {verdict}')
duplicate_discord_identities = []
if args.check_discord_identities:
print('\n' + '=' * 78)
print('Duplicate Discord identities (SEC-012)')
print('=' * 78)
try:
duplicate_discord_identities = find_duplicate_discord_identities(engine)
except SQLAlchemyError as exc:
print(f'Could not check: {exc}')
else:
if not duplicate_discord_identities:
print('No Discord identity is shared by multiple accounts.')
for discord_user_id, usernames, account_count in duplicate_discord_identities:
print(f' {discord_user_id}: {account_count} accounts ({usernames})')
blocking = sum(1 for f in findings if f.severity == BLOCKING)
print(f'\n{len(findings)} finding(s), {blocking} blocking.')
return 1 if findings or duplicate_discord_identities else 0
if __name__ == '__main__': # pragma: no cover
sys.exit(main())
@@ -12,12 +12,12 @@ Usage:
python security_scan.py [--url http://localhost:5000] python security_scan.py [--url http://localhost:5000]
""" """
import os
import sys
import json import json
import subprocess import os
import urllib.request
import ssl import ssl
import subprocess
import sys
import urllib.request
from datetime import datetime from datetime import datetime
@@ -128,7 +128,9 @@ def check_https_headers(url):
all_ok = False all_ok = False
if 'SameSite' in cookie: if 'SameSite' in cookie:
print(f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}') print(
f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}'
)
else: else:
print('[WARN] Cookies missing SameSite attribute') print('[WARN] Cookies missing SameSite attribute')
all_ok = False all_ok = False
@@ -168,29 +170,33 @@ def check_dependencies():
[sys.executable, '-m', 'pip_audit', '--format', 'json'], [sys.executable, '-m', 'pip_audit', '--format', 'json'],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60 timeout=60,
) )
if result.returncode == 0: if result.returncode == 0:
print('[OK] No known vulnerabilities found') print('[OK] No known vulnerabilities found')
return True return True
else: try:
try: data = json.loads(result.stdout)
data = json.loads(result.stdout) # pip-audit's "dependencies" array lists EVERY dependency, each
vulns = data.get('dependencies', []) # carrying a "vulns" list that is empty when the package is
if vulns: # clean. Treating the array itself as the vulnerability list
for vuln in vulns: # reported all ~45 installed packages as vulnerable on every
print(f'[FAIL] {vuln["name"]}=={vuln["version"]}: {vuln.get("description", "Vulnerability found")}') # run, which is why this check was pure noise.
return False affected = [dep for dep in data.get('dependencies', []) if dep.get('vulns')]
else: if affected:
print('[OK] No vulnerabilities found') for dep in affected:
return True ids = ', '.join(v.get('id', '?') for v in dep.get('vulns', []))
except json.JSONDecodeError: print(f'[FAIL] {dep["name"]}=={dep["version"]}: {ids}')
if result.stdout: return False
print(f'[INFO] {result.stdout.strip()}') print('[OK] No vulnerabilities found')
if result.stderr: return True
print(f'[WARN] {result.stderr.strip()}') except json.JSONDecodeError:
return True if result.stdout:
print(f'[INFO] {result.stdout.strip()}')
if result.stderr:
print(f'[WARN] {result.stderr.strip()}')
return True
except FileNotFoundError: except FileNotFoundError:
print('[SKIP] pip-audit not installed. Run: pip install pip-audit') print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
return True return True
@@ -215,7 +221,7 @@ def check_file_permissions():
gitignore_path = os.path.join(os.getcwd(), '.gitignore') gitignore_path = os.path.join(os.getcwd(), '.gitignore')
if os.path.exists(gitignore_path): if os.path.exists(gitignore_path):
required_patterns = ['.env', 'instance/', '*.db', '*.log'] required_patterns = ['.env', 'instance/', '*.db', '*.log']
with open(gitignore_path, 'r') as f: with open(gitignore_path) as f:
content = f.read() content = f.read()
for pattern in required_patterns: for pattern in required_patterns:
@@ -237,7 +243,7 @@ def check_file_permissions():
# Check for leftover .pyc or __pycache__ # Check for leftover .pyc or __pycache__
pycache_count = 0 pycache_count = 0
for root, dirs, files in os.walk(os.getcwd()): for _root, dirs, files in os.walk(os.getcwd()):
if '__pycache__' in dirs: if '__pycache__' in dirs:
pycache_count += 1 pycache_count += 1
for f in files: for f in files:
@@ -264,8 +270,22 @@ def check_flask_config():
all_ok = True all_ok = True
try: try:
from app import create_app # The script lives two levels below the project root; without this the
app = create_app() # import fails and the whole check was silently skipped.
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if root not in sys.path:
sys.path.insert(0, root)
from app.app import create_app
# Inspect configuration only: no schema creation, no Discord bot.
app = create_app(
{
'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:',
'AUTO_CREATE_TABLES': False,
'ENABLE_DISCORD_BOT': False,
}
)
# Check session cookie settings # Check session cookie settings
cookie_checks = [ cookie_checks = [
@@ -312,8 +332,17 @@ def check_flask_config():
else: else:
print('[OK] DEBUG mode: disabled') print('[OK] DEBUG mode: disabled')
except Exception as e: except Exception as e: # noqa: BLE001 — any failure to load the app is a failed check
print(f'[SKIP] Cannot check Flask config: {e}') # Returning all_ok (still True) here meant that failing to load the
# application at all was counted as a passing check — the most
# important section of the report silently never ran.
#
# The breadth is the point: this section's question is "does the
# application load with a safe configuration", and every way of not
# loading answers it the same way. Reported on stdout because this
# script is read by a CI job, not by a log collector.
print(f'[FAIL] Cannot check Flask config: {e}')
return False
return all_ok return all_ok
@@ -327,19 +356,32 @@ def main():
import argparse import argparse
parser = argparse.ArgumentParser(description='Security validation scanner') parser = argparse.ArgumentParser(description='Security validation scanner')
parser.add_argument('--url', default='http://localhost:5000', parser.add_argument(
help='Application URL to check headers (default: http://localhost:5000)') '--url',
default='http://localhost:5000',
help='Application URL to check headers (default: http://localhost:5000)',
)
parser.add_argument(
'--skip-http',
action='store_true',
help='Skip the live HTTP header check (no server running, e.g. in CI)',
)
args = parser.parse_args() args = parser.parse_args()
print('╔══════════════════════════════════════════════════════════╗') # Plain ASCII: the box-drawing characters this banner used crashed the
print('║ TEAM TRYOUTS - SECURITY VALIDATION SCANNER ║') # script outright on a cp1252 Windows console, which is the platform the
print('╠══════════════════════════════════════════════════════════╣') # project is developed and deployed on.
print(f'║ Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') print('=' * 60)
print('╚══════════════════════════════════════════════════════════╝') print('TEAM TRYOUTS - SECURITY VALIDATION SCANNER')
print(f'Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
print('=' * 60)
checks = [ checks = [check_environment]
check_environment, if args.skip_http:
lambda: check_https_headers(args.url), print('\n[SKIP] HTTP header check disabled via --skip-http')
else:
checks.append(lambda: check_https_headers(args.url))
checks += [
check_dependencies, check_dependencies,
check_file_permissions, check_file_permissions,
check_flask_config, check_flask_config,
@@ -363,9 +405,8 @@ def main():
if failed == 0: if failed == 0:
print('\n[OK] All security checks passed!') print('\n[OK] All security checks passed!')
return 0 return 0
else: print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
print(f'\n[WARN] {failed} check(s) failed. Review the output above.') return 1
return 1
if __name__ == '__main__': if __name__ == '__main__':
+15
View File
@@ -0,0 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('400 Bad Request') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Bad Request') }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h2>{{ _('400 — Bad Request') }}</h2>
<p>{{ _('The request could not be understood by the server. Please check your input and try again.') }}</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a>
</div>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('403 Forbidden') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Access Denied') }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-lock"></i>
</div>
<h2>{{ _('403 — Forbidden') }}</h2>
<p>{{ _('You do not have permission to access this resource. If you believe this is an error, please contact an administrator.') }}</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a>
</div>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('404 Not Found') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Page Not Found') }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-search"></i>
</div>
<h2>{{ _('404 — Not Found') }}</h2>
<p>{{ _('The page you are looking for does not exist. It may have been moved or deleted.') }}</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-home"></i> {{ _('Return Home') }}
</a>
</div>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('429 Too Many Requests') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Rate Limit Exceeded') }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-hourglass-half"></i>
</div>
<h2>{{ _('429 — Too Many Requests') }}</h2>
<p>{{ _('You have sent too many requests in a short period. Please wait a moment and try again.') }}</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a>
</div>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('500 Server Error') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Internal Server Error') }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-cogs"></i>
</div>
<h2>{{ _('500 — Internal Server Error') }}</h2>
<p>{{ _('Something went wrong on our end. The error has been logged and will be investigated. Please try again later.') }}</p>
{# The reference is what makes a report actionable: it names one request
in errors.log. It identifies nothing else — no session, no account —
so there is nothing to protect here (OBS-005). #}
{% if request_id and request_id != '-' %}
<p class="text-muted small">{{ _('Reference to quote if you report this:') }} <code>{{ request_id }}</code></p>
{% endif %}
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-redo-alt"></i> {{ _('Try Again') }}
</a>
</div>
{% endblock %}
@@ -0,0 +1,19 @@
{# Language switcher.
Included from both branches of the layout: the signed-in sidebar and the
anonymous authentication page. Someone who cannot read the current
language has to be able to change it *before* signing in, so this cannot
live behind the login.
Each language is written in its own language, for the same reason. #}
<i class="fas fa-language" aria-hidden="true"></i>
<span class="sr-only">{{ _('Language') }}</span>
{% for code in supported_locales %}
{%- if code == current_locale %}
<span class="lang-current" aria-current="true">{{ locale_names[code] }}</span>
{%- else %}
<a href="{{ url_for('main.set_language', locale=code) }}"
class="lang-link" hreflang="{{ code }}" rel="alternate">{{ locale_names[code] }}</a>
{%- endif %}
{%- if not loop.last %}<span class="lang-separator" aria-hidden="true">·</span>{% endif %}
{% endfor %}
+41
View File
@@ -0,0 +1,41 @@
{#
Pagination controls (MNT-14).
Import and call:
{% import 'layouts/_pagination.html' as pager %}
{{ pager.controls(pagination) }}
`page_url` is a Jinja global registered in app.py; it rebuilds the current
URL at another page number, keeping the rest of the query string. That is
the part that gets forgotten — dropping `sort` or `team_id` from a
pagination link silently resets the view someone was looking at.
Plain <a> links only: no inline handler, nothing for CSP to refuse
(tests/test_csp.py).
#}
{% macro controls(pagination) %}
{% if pagination.pages > 1 %}
<nav class="pagination" aria-label="{{ _('Pagination') }}">
{% if pagination.has_prev %}
<a class="btn btn-secondary btn-sm" href="{{ page_url(pagination.prev_num) }}"
rel="prev">&laquo; {{ _('Previous') }}</a>
{% else %}
<span class="btn btn-secondary btn-sm is-disabled" aria-disabled="true">&laquo; {{ _('Previous') }}</span>
{% endif %}
<span class="pagination-status">
{{ _('Page %(page)s of %(pages)s', page=pagination.page, pages=pagination.pages) }}
&middot;
{{ _('%(total)s in total', total=pagination.total) }}
</span>
{% if pagination.has_next %}
<a class="btn btn-secondary btn-sm" href="{{ page_url(pagination.next_num) }}"
rel="next">{{ _('Next') }} &raquo;</a>
{% else %}
<span class="btn btn-secondary btn-sm is-disabled" aria-disabled="true">{{ _('Next') }} &raquo;</span>
{% endif %}
</nav>
{% endif %}
{% endmacro %}
@@ -1,10 +1,25 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{{ current_locale }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Team Tryout Management{% endblock %}</title> <title>{% block title %}UdeS team manager{% endblock %}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> {# Subresource integrity (QUA-004). Without it, whoever controls the CDN
controls what runs on every page of this site — and the CSP names these
hosts as allowed, so it would not object.
What SRI does and does not do: it pins this exact file, so the browser
refuses a version that has been altered since. It does not prove the
file was honest when the hash was taken. This hash is the one cdnjs
publishes for the release, not one derived from the copy we downloaded.
integrity requires crossorigin. Changing the version means changing
the hash, or the asset silently stops loading. #}
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous"
referrerpolicy="no-referrer">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏆</text></svg>"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏆</text></svg>">
</head> </head>
@@ -13,8 +28,8 @@
<nav class="sidebar" id="sidebar"> <nav class="sidebar" id="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo"> <div class="logo">
<i class="fas fa-trophy"></i> <img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="logo-img">
<span>TryoutPro</span> <span>UdeS team manager</span>
</div> </div>
<div class="user-badge"> <div class="user-badge">
<div class="user-avatar"> <div class="user-avatar">
@@ -30,103 +45,111 @@
<li> <li>
<a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint and 'dashboard' in request.endpoint %}active{% endif %}"> <a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint and 'dashboard' in request.endpoint %}active{% endif %}">
<i class="fas fa-th-large"></i> <i class="fas fa-th-large"></i>
<span>Dashboard</span> <span>{{ _('Dashboard') }}</span>
</a> </a>
</li> </li>
<li> <li>
<a href="{{ url_for('tryouts.list_tryouts') }}" class="{% if request.endpoint and 'tryouts' in request.endpoint and request.endpoint != 'tryouts.create_tryout' %}active{% endif %}"> <a href="{{ url_for('tryouts.list_tryouts') }}" class="{% if request.endpoint and 'tryouts' in request.endpoint and request.endpoint != 'tryouts.create_tryout' %}active{% endif %}">
<i class="fas fa-calendar-alt"></i> <i class="fas fa-calendar-alt"></i>
<span>Tryouts</span> <span>{{ _('Tryouts') }}</span>
</a> </a>
</li> </li>
<li> <li>
<a href="{{ url_for('matches.calendar') }}" class="{% if request.endpoint and 'calendar' in request.endpoint %}active{% endif %}"> <a href="{{ url_for('matches.calendar') }}" class="{% if request.endpoint and 'calendar' in request.endpoint %}active{% endif %}">
<i class="fas fa-calendar"></i> <i class="fas fa-calendar"></i>
<span>Calendar</span> <span>{{ _('Calendar') }}</span>
</a> </a>
</li> </li>
{% if current_user.can_evaluate() %} {% if current_user.can_evaluate() %}
<li> <li>
<a href="{{ url_for('evaluations.list_evaluations') }}" class="{% if request.endpoint and 'evaluations' in request.endpoint %}active{% endif %}"> <a href="{{ url_for('evaluations.list_evaluations') }}" class="{% if request.endpoint and 'evaluations' in request.endpoint %}active{% endif %}">
<i class="fas fa-clipboard-check"></i> <i class="fas fa-clipboard-check"></i>
<span>Evaluations</span> <span>{{ _('Evaluations') }}</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if current_user.role == 'player' %}
<li> <li>
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint %}active{% endif %}"> <a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
<i class="fas fa-users-cog"></i> <i class="fas fa-users"></i>
<span>Teams</span> <span>{{ _('My Team(s)') }}</span>
</a> </a>
</li> </li>
{% else %}
<li>
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}">
<i class="fas fa-users-cog"></i>
<span>{{ _('Manage Teams') }}</span>
</a>
</li>
{% endif %}
{% if current_user.can_manage_users() %} {% if current_user.can_manage_users() %}
<li> <li>
<a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}"> <a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}">
<i class="fas fa-users-cog"></i> <i class="fas fa-users-cog"></i>
<span>Manage Users</span> <span>{{ _('Manage Users') }}</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
<li>
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
<i class="fas fa-user"></i>
<span>My Profile</span>
</a>
</li>
{% if current_user.role == 'player' %} {% if current_user.role == 'player' %}
<li>
<a href="{{ url_for('users.one_on_one') }}" class="{% if request.endpoint == 'users.one_on_one' %}active{% endif %}">
<i class="fas fa-calendar-check"></i>
<span>One on One</span>
</a>
</li>
<li> <li>
<a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}"> <a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}">
<i class="fas fa-sticky-note"></i> <i class="fas fa-sticky-note"></i>
<span>My Notes</span> <span>{{ _('My Notes') }}</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if current_user.role == 'coach' %} {% if current_user.role == 'coach' %}
<li>
<a href="{{ url_for('users.manage_coach_availability') }}" class="{% if request.endpoint == 'users.manage_coach_availability' %}active{% endif %}">
<i class="fas fa-clock"></i>
<span>Availability</span>
</a>
</li>
<li> <li>
<a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}"> <a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}">
<i class="fas fa-sticky-note"></i> <i class="fas fa-sticky-note"></i>
<span>Notes</span> <span>{{ _('Notes & One on One') }}</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
<li> <li>
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}"> <a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
<i class="fas fa-file-contract"></i> <i class="fas fa-file-contract"></i>
<span>Contracts</span> <span>{{ _('Contracts') }}</span>
</a> </a>
</li> </li>
<li class="nav-divider"></li> <li class="nav-divider"></li>
<li> <li>
<a href="{{ url_for('auth.logout') }}" class="logout-link"> <a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
<i class="fas fa-sign-out-alt"></i> <i class="fas fa-user"></i>
<span>Logout</span> <span>{{ _('My Profile') }}</span>
</a> </a>
</li> </li>
<li>
{# A form, not a link: logging out is a state change, and a
GET route carries no CSRF token — any site could sign the
user out with an <img> tag. Styled as a nav entry. #}
<form method="POST" action="{{ url_for('auth.logout') }}" class="nav-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="logout-link">
<i class="fas fa-sign-out-alt"></i>
<span>{{ _('Logout') }}</span>
</button>
</form>
</li>
<li class="nav-divider"></li>
<li class="nav-language">
{% include "layouts/_language_switcher.html" %}
</li>
</ul> </ul>
</nav> </nav>
<div class="main-content" id="mainContent"> <div class="main-content" id="mainContent">
<header class="top-bar"> <header class="top-bar">
<button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebar()"> <button class="sidebar-toggle" id="sidebarToggle" data-action="toggle-sidebar">
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </button>
<div class="page-header"> <div class="page-header">
<h1>{% block page_title %}Dashboard{% endblock %}</h1> <h1>{% block page_title %}{{ _('Dashboard') }}{% endblock %}</h1>
{% block breadcrumb %}{% endblock %} {% block breadcrumb %}{% endblock %}
</div> </div>
<button class="dark-mode-toggle" id="darkModeToggle" onclick="toggleDarkMode()" title="Toggle dark mode"> <button class="dark-mode-toggle" id="darkModeToggle" data-action="toggle-dark-mode"
title="{{ _('Toggle dark mode') }}">
<i class="fas fa-moon"></i> <i class="fas fa-moon"></i>
</button> </button>
{% block header_actions %}{% endblock %} {% block header_actions %}{% endblock %}
@@ -137,7 +160,8 @@
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible"> <div class="alert alert-{{ category }} alert-dismissible">
<span>{{ message }}</span> <span>{{ message }}</span>
<button type="button" class="alert-close" onclick="this.parentElement.remove()">&times;</button> <button type="button" class="alert-close" data-action="dismiss-alert"
aria-label="{{ _('Dismiss') }}">&times;</button>
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@@ -155,7 +179,8 @@
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible"> <div class="alert alert-{{ category }} alert-dismissible">
<span>{{ message }}</span> <span>{{ message }}</span>
<button type="button" class="alert-close" onclick="this.parentElement.remove()">&times;</button> <button type="button" class="alert-close" data-action="dismiss-alert"
aria-label="{{ _('Dismiss') }}">&times;</button>
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@@ -163,10 +188,27 @@
</div> </div>
<div class="auth-container"> <div class="auth-container">
<div class="auth-header"> <div class="auth-header">
<i class="fas fa-trophy"></i> <img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="auth-logo-img">
<h2>TryoutPro</h2> <h2>UdeS team manager</h2>
<p>Team Tryout Management System</p> <p>{{ _('UdeS team manager') }}</p>
</div> </div>
<div class="auth-language">
{% include "layouts/_language_switcher.html" %}
</div>
{# The same `content` block as the signed-in branch, rendered
here too — `self.content()` rather than a second
`{% block %}`, which Jinja refuses.
The error pages (400, 403, 404, 429, 500) all fill `content`,
and it existed only inside the `is_authenticated` branch: a
signed-out visitor hitting any of them got the logo, the
language switcher and no message whatsoever. The <title> still
said "404", which is most of why nobody noticed.
Only one branch of the `if` runs, so this never double-renders.
Sign-in pages fill `auth_content` instead and leave this
empty. #}
{{ self.content() }}
{% block auth_content %}{% endblock %} {% block auth_content %}{% endblock %}
</div> </div>
</div> </div>
@@ -7,7 +7,7 @@
{# Page Header Macro - renders title and breadcrumb #} {# Page Header Macro - renders title and breadcrumb #}
{% macro page_header(title, breadcrumb) %} {% macro page_header(title, breadcrumb) %}
{% block title %}{{ title }} - TryoutPro{% endblock %} {% block title %}{{ title }} - UdeS team manager{% endblock %}
{% block page_title %}{{ title }}{% endblock %} {% block page_title %}{{ title }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ breadcrumb }}</span>{% endblock %} {% block breadcrumb %}<span class="breadcrumb">{{ breadcrumb }}</span>{% endblock %}
{% endmacro %} {% endmacro %}
@@ -108,11 +108,12 @@
{# Modal Macro - renders a modal dialog #} {# Modal Macro - renders a modal dialog #}
{% macro modal(id, title, content, footer_buttons=None) %} {% macro modal(id, title, content, footer_buttons=None) %}
<div id="{{ id }}" class="modal hidden"> <div id="{{ id }}" class="modal hidden">
<div class="modal-backdrop" onclick="hideModal('{{ id }}')"></div> <div class="modal-backdrop" data-action="hide-modal" data-modal-id="{{ id }}"></div>
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>{{ title }}</h3> <h3>{{ title }}</h3>
<button class="modal-close" onclick="hideModal('{{ id }}')">&times;</button> <button class="modal-close" data-action="hide-modal" data-modal-id="{{ id }}"
aria-label="{{ _('Close') }}">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{{ content }} {{ content }}

Some files were not shown because too many files have changed in this diff Show More