Author SHA1 Message Date
dependabot[bot] c03ecc8cfe Bump actions/setup-python from 5 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-27 16:28:04 +00:00
242 changed files with 9442 additions and 36403 deletions
-45
View File
@@ -1,45 +0,0 @@
name: CI - Security, Lint & Tests
on:
push:
pull_request:
workflow_dispatch:
# This workflow validates branches only. It has no deployment step and no
# write permission, so an audit-branch push cannot alter main or production.
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
cache: pip
- name: Install dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Audit declared dependencies
run: pip-audit -r requirements.txt
- name: Lint and check formatting
run: |
ruff check .
ruff format --check .
- name: Run tests with coverage gate
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
- name: Run repository security checks
env:
SECRET_KEY: audit-ci-key-not-for-production-1234567890
DATABASE_URL: 'sqlite:///:memory:'
FLASK_DEBUG: 'false'
run: python app/supporting_scripts/security_scan.py --skip-http
-152
View File
@@ -1,152 +0,0 @@
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."
+26 -54
View File
@@ -2,7 +2,7 @@ name: CI - Security & Lint
on:
push:
branches: [main, master, 'audit/**']
branches: [main, master]
pull_request:
branches: [main, master]
workflow_dispatch: # Allow manual triggers
@@ -11,112 +11,84 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
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:
security-audit:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v7
with:
python-version: ${{ env.PYTHON_VERSION }}
python-version: '3.12'
cache: 'pip'
- name: Install pip-audit
run: pip install pip-audit==2.9.0
- name: Install dependencies
run: pip install pip-audit
# Previously: `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
- name: Scan for vulnerable dependencies
run: pip-audit --require-hashes --no-deps || pip-audit
lint:
name: Lint with Ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v7
with:
python-version: ${{ env.PYTHON_VERSION }}
python-version: '3.12'
- name: Install ruff
run: pip install ruff==0.14.4
run: pip install ruff
# 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
run: ruff check . --output-format=github
# 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
- name: Run ruff formatter check
run: ruff format --check .
security-scan:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v7
with:
python-version: ${{ env.PYTHON_VERSION }}
python-version: '3.12'
cache: 'pip'
- name: Install app dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
run: pip install -r requirements.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
env:
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
DATABASE_URL: 'sqlite:///:memory:'
FLASK_DEBUG: 'false'
run: python app/supporting_scripts/security_scan.py --skip-http
run: python security_scan.py --skip-http
test:
name: Tests
runs-on: ubuntu-latest
needs: [security-audit, lint]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v7
with:
python-version: ${{ env.PYTHON_VERSION }}
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
run: pip install -r requirements.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
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
run: |
echo "No tests configured yet. Add tests to the project."
# python -m pytest tests/ --cov=. --cov-report=xml
continue-on-error: true
+13 -143
View File
@@ -1,151 +1,21 @@
# =============================================================================
# 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
.env
*.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/
.instance/
*.db
*.sqlite
*.sqlite3
# Contrats téléversés (données personnelles)
documents/
# Sauvegardes produites par app/supporting_scripts/backup.py
backups/
__pycache__/
*.cpython-313.pyc
*.cpython-312.pyc
*.pyc
# Journaux applicatifs
logs/
.pytest_cache/
.coverage
htmlcov/
.DS_Store
*.log
# État d'exécution du bot Discord
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
.certs/
*.pem
+60 -134
View File
@@ -1,153 +1,79 @@
# Plateforme centralisée de tryouts
### Plateforme centralisée de tryouts
Application interne du club e-sport de l'UdeS : inscriptions aux sélections,
évaluations, gestion des équipes, disponibilités, contrats, et notifications
Discord.
## Security Configuration
Le site est servi **en français**, l'anglais reste accessible par le sélecteur
de la barre latérale (voir `docs/translations.md`).
### Required Environment Variables
---
Before deploying, create a `.env` file with the following:
## Démarrer
```
# Flask Configuration (REQUIRED)
SECRET_KEY=your-secure-random-secret-key-here
```bash
python -m venv .venv
.venv/Scripts/pip install -r requirements.txt -r requirements-dev.txt
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
# Production Settings
FLASK_DEBUG=false
FORCE_HTTPS=true
SESSION_COOKIE_SECURE=true
```
`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.
### Security Features Implemented
Production : `python wsgi.py` (Waitress derrière nginx). Voir
`docs/deployment.md`.
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks
- **Secure Session Cookies**: HTTPOnly, SameSite=Lax, and Secure flags enabled
- **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
Ce sont les **deux seuls** points d'entrée.
## Discord Integration for One on One Requests
## Vérifier
The application supports sending Discord direct messages to coaches when players request One on One sessions.
```bash
.venv/Scripts/python -m pytest # suite complète
.venv/Scripts/python -m ruff check . # lint
.venv/Scripts/python -m ruff format --check .
### Setup Instructions
#### 1. Create a Discord Bot
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
```
Les trois tournent en CI et y sont bloquants.
- `DISCORD_BOT_TOKEN`: Required for sending direct messages to coaches
- `DISCORD_WEBHOOK_URL`: Optional fallback for webhook-based notifications
---
#### 3. Add Coaches to the Bot
## Ce que fait l'application
For the bot to send DMs to coaches:
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
- **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.
### How It Works
## Comment c'est construit
When a player submits a One on One request:
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
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.
### Message Format
`docs/architecture.md` contient les diagrammes (classes, paquets, flux
d'une requête).
---
## 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é.
The Discord DM includes:
- Player name
- Team name
- Requested date and time slot
- Discussion points (if provided)
- Link to the application for approval/rejection
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
@@ -0,0 +1,379 @@
"""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
@@ -1,144 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 148 KiB

-47
View File
@@ -1,47 +0,0 @@
"""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
@@ -1,726 +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
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).
-1586
View File
File diff suppressed because it is too large Load Diff
-93
View File
@@ -1,93 +0,0 @@
"""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
def form_gamertags(selected_games):
"""Validate the dynamic gamertag fields for the selected games.
These fields cannot be declared statically on the account schemas: their
names contain the game label. They are still untrusted form data, so
every caller uses this shared boundary before adding or changing rows.
"""
from marshmallow import ValidationError
from app.models import GAME_PLATFORMS
from app.validators import GamertagSchema
validated = {}
for game in selected_games:
raw_gamertag = request.form.get(f'gamertag_{game}', '')
raw_platform = (
request.form.get(f'platform_{game}', '') if GAME_PLATFORMS.get(game) else None
)
if not raw_gamertag.strip():
continue
try:
validated[game] = GamertagSchema().load(
{'game': game, 'gamertag': raw_gamertag, 'platform': raw_platform}
)
except ValidationError as err:
messages = [message for values in err.messages.values() for message in values]
raise ValidationError({f'gamertag_{game}': messages}) from err
return validated
-75
View File
@@ -1,75 +0,0 @@
"""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
@@ -1,273 +0,0 @@
"""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))
-96
View File
@@ -1,96 +0,0 @@
"""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,
EVALUATION_CRITERIA,
)
# =========================================================================
# 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
@@ -1,39 +0,0 @@
"""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
),
)
-81
View File
@@ -1,81 +0,0 @@
"""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']
# Ordered list of (field_name, human_label) pairs for the player evaluation
# score criteria. Kept in a single place so the evaluation forms, batch
# evaluation page, and any future reporting all stay in sync.
EVALUATION_CRITERIA = [
('mecanics_score', 'Mecanics'),
('cohesion_score', 'Cohesion'),
('communication_score', 'Communication'),
('gamesense_score', 'Gamesense'),
('versatility_score', 'Versatility'),
('discipline_score', 'Discipline'),
('analysis_score', 'Analysis'),
('sport_ethics_score', 'Sport Ethics'),
('mental_score', 'Mental'),
]
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
@@ -1,29 +0,0 @@
"""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
@@ -1,7 +0,0 @@
"""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']
-16
View File
@@ -1,16 +0,0 @@
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
@@ -1,14 +0,0 @@
"""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')
@@ -1,14 +0,0 @@
"""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')
-67
View File
@@ -1,67 +0,0 @@
"""Contract documents for players to sign."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
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 = db.session.get(User, 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
-78
View File
@@ -1,78 +0,0 @@
"""Player evaluation record."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
__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
@@ -1,7 +0,0 @@
"""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']
-20
View File
@@ -1,20 +0,0 @@
"""Abstract base class for match models (Match + TeamMatch)."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
-30
View File
@@ -1,30 +0,0 @@
"""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
@@ -1,24 +0,0 @@
"""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)
-27
View File
@@ -1,27 +0,0 @@
"""Request from player to coach for a One on One session."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
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
@@ -1,6 +0,0 @@
"""Organisation team models."""
from app.models.org_team.org_team import OrgTeam
from app.models.org_team.team_player import TeamPlayer
__all__ = ['OrgTeam', 'TeamPlayer']
-67
View File
@@ -1,67 +0,0 @@
"""Persistent organisation team (e.g. Varsity, JV)."""
from app.extensions import db
from app.models._associations import org_team_coaches, org_team_managers
from app.time_utils import utc_now_naive
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=utc_now_naive)
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
]
-23
View File
@@ -1,23 +0,0 @@
"""Many-to-many junction: player to org-team."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
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
@@ -1,7 +0,0 @@
"""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']
-13
View File
@@ -1,13 +0,0 @@
"""Abstract base class for match participant models."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
@@ -1,17 +0,0 @@
"""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')
@@ -1,15 +0,0 @@
"""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')
-26
View File
@@ -1,26 +0,0 @@
"""Personal notes from coach to individual player."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
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
@@ -1,6 +0,0 @@
"""Tryout-specific temporary team models."""
from app.models.team.team import Team
from app.models.team.team_member import TeamMember
__all__ = ['Team', 'TeamMember']
-18
View File
@@ -1,18 +0,0 @@
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
creator = db.relationship('User', backref='created_teams')
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
-17
View File
@@ -1,17 +0,0 @@
"""Link between a player and a tryout-specific team."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
player = db.relationship('User', overlaps="player_ref,team_assignments")
-19
View File
@@ -1,19 +0,0 @@
"""Team improvement notes from coach."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
team = db.relationship('OrgTeam', backref='team_notes')
coach = db.relationship('User', foreign_keys=[coach_id])
-6
View File
@@ -1,6 +0,0 @@
"""Tryout models."""
from app.models.tryout.tryout import Tryout
from app.models.tryout.tryout_registration import TryoutRegistration
__all__ = ['Tryout', 'TryoutRegistration']
-49
View File
@@ -1,49 +0,0 @@
"""Tryout event for player evaluations and team formation."""
from app.extensions import db
from app.models._associations import tryout_coaches
from app.time_utils import utc_now_naive
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=utc_now_naive)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
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
-16
View File
@@ -1,16 +0,0 @@
"""Registration linking a player to a tryout."""
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
status = db.Column(db.String(20), default='registered')
notes = db.Column(db.Text, nullable=True)
-45
View File
@@ -1,45 +0,0 @@
"""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
@@ -1,10 +0,0 @@
"""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
@@ -1,35 +0,0 @@
"""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
@@ -1,42 +0,0 @@
"""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
@@ -1,38 +0,0 @@
"""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
@@ -1,44 +0,0 @@
"""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
@@ -1,17 +0,0 @@
"""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()
-107
View File
@@ -1,107 +0,0 @@
"""Base User model — shared fields and polymorphic configuration."""
from flask_login import UserMixin
from app.extensions import db
from app.time_utils import utc_now_naive
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=utc_now_naive)
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 []
-64
View File
@@ -1,64 +0,0 @@
"""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
@@ -1,345 +0,0 @@
"""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
-711
View File
@@ -1,711 +0,0 @@
"""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 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.forms import form_gamertags
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.time_utils import utc_now_naive
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 = utc_now_naive() + 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', [])
try:
submitted_gamertags = form_gamertags(selected_games)
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)
# 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, gamertag_data in submitted_gamertags.items():
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_data['gamertag'],
platform=gamertag_data['platform'],
)
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'))
-346
View File
@@ -1,346 +0,0 @@
"""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 flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, Coach, Manager, Player,
User, Tryout, Evaluation, TryoutRegistration,
OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
)
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')
def _users_by_id(user_ids):
"""Load a set of users once for aggregate/list views."""
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()}
@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()
)
players_by_id = _users_by_id(row.player_id for row in avg_scores)
player_scores = {}
for row in avg_scores:
p = players_by_id.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 = db.get_or_404(Tryout, 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 = db.get_or_404(User, 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_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
evaluators = [
{'evaluator': evaluators_by_id.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 = db.get_or_404(Tryout, 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_by_id = _users_by_id(reg.player_id for reg in registrations)
evaluated_player_ids = {
player_id
for (player_id,) in db.session.query(Evaluation.player_id)
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
.all()
}
players = [
{
'player': player,
'evaluated': player.id in evaluated_player_ids,
'registration': registration,
}
for registration in registrations
if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player)
]
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
@evaluations_bp.route('/<int:tryout_id>/batch', methods=['GET', 'POST'])
@login_required
def batch_evaluate(tryout_id):
"""Evaluate multiple players at once in a tryout.
GET renders a single form listing every selected player with their
evaluation criteria. POST saves (creates or updates) all of them.
"""
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'))
# Resolve selected player ids (query string on GET, hidden fields on POST).
player_ids = []
for raw in request.values.getlist('player_ids'):
try:
pid = int(raw)
except (ValueError, TypeError):
continue
if pid not in player_ids:
player_ids.append(pid)
if not player_ids:
flash('Please select at least one player to evaluate.', 'warning')
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
players = []
for pid in player_ids:
player = User.query.get(pid)
if not player or not isinstance(player, Player):
continue
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=pid,
).first() is not None
if not is_registered:
continue
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
).first()
existing_scores = {
field_name: getattr(existing, field_name) if existing else None
for field_name, _ in EVALUATION_CRITERIA
}
players.append({
'player': player,
'existing': existing,
'existing_scores': existing_scores,
})
if not players:
flash('No valid players selected for evaluation.', 'danger')
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
if request.method == 'POST':
saved = 0
for entry in players:
pid = entry['player'].id
scores = {
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
for field_name, _ in EVALUATION_CRITERIA
}
comments = request.form.get(f'comments_{pid}')
position = request.form.get(f'position_recommendation_{pid}')
existing = entry['existing']
if existing:
_apply_evaluation(existing, scores, comments, position)
else:
evaluation = Evaluation(
tryout_id=tryout_id, player_id=pid,
evaluator_id=current_user.id,
)
_apply_evaluation(evaluation, scores, comments, position)
db.session.add(evaluation)
saved += 1
db.session.commit()
flash(f'Saved evaluations for {saved} player(s).', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template('pages/batch_evaluate.html',
tryout=tryout, players=players,
evaluation_criteria=EVALUATION_CRITERIA,
game_positions=GAME_POSITIONS)
-240
View File
@@ -1,240 +0,0 @@
"""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()
top_rows = (
db.session.query(
User,
func.avg(Evaluation.overall_score).label('avg_score'),
)
.join(Evaluation, Evaluation.player_id == User.id)
.filter(User.role == 'player')
.group_by(User.id)
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
.limit(5)
.all()
)
stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
return render_template('pages/dashboard.html', user=user, stats=stats)
-691
View File
@@ -1,691 +0,0 @@
"""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=())
def registered_players(tryout_id):
"""Players registered for one tryout, loaded in a single query.
The create form previously called ``User.query.get`` twice per
registration (once in the filter and once in the result expression),
and the edit form called it once per row. Besides scaling linearly, both
paths could return duplicates while DB-006 is still pending. The join is
bounded and ``distinct`` preserves the form's intended one-option-per-
player contract until the database constraint lands.
"""
return (
User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id)
.filter(TryoutRegistration.tryout_id == tryout_id)
.order_by(User.username)
.distinct()
.all()
)
#: 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 = db.get_or_404(Tryout, 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 = db.get_or_404(Tryout, 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()
all_players = registered_players(tryout_id)
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 = db.get_or_404(Match, 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()
all_players = registered_players(tryout.id)
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 = db.get_or_404(Match, 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 = db.get_or_404(Match, match_id)
tryout = match.tryout
participant = db.get_or_404(MatchParticipant, 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',
}
)
-314
View File
@@ -1,314 +0,0 @@
"""Team match management routes for regular season matches.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
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.time_utils import utc_now_naive
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=utc_now_naive(),
)
@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 = db.get_or_404(OrgTeam, 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 = db.get_or_404(TeamMatch, 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 = db.get_or_404(TeamMatch, 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 = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
participant = db.get_or_404(TeamMatchParticipant, 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',
}
)
-619
View File
@@ -1,619 +0,0 @@
"""Organization team management routes.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, flash, jsonify, redirect, render_template, 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.time_utils import utc_now_naive
from app.validators import NoteContentSchema, 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 = utc_now_naive()
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 = db.get_or_404(OrgTeam, 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 = db.get_or_404(OrgTeam, 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 = db.get_or_404(OrgTeam, 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 = db.get_or_404(OrgTeam, 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 = db.get_or_404(OrgTeam, 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 = db.get_or_404(OrgTeam, 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 = db.get_or_404(OrgTeam, 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 = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = db.get_or_404(User, 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 = db.get_or_404(OrgTeam, 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 = db.get_or_404(OrgTeam, 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'))
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['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 = db.get_or_404(OrgTeam, 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 = db.get_or_404(User, 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'))
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('teams.list_teams'))
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['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'))
-699
View File
@@ -1,699 +0,0 @@
"""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 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 sqlalchemy import select
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.time_utils import utc_now_naive
from app.validators import (
PlayerSelectionSchema,
TryoutRegistrationStatusSchema,
TryoutSchema,
TryoutStatusSchema,
TryoutTeamMemberSchema,
TryoutTeamSchema,
)
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()}
def registration_lock_statement(tryout_id):
"""The PostgreSQL row lock used by both registration entry points."""
return select(Tryout).where(Tryout.id == tryout_id).with_for_update()
def locked_tryout_or_404(tryout_id):
"""Load and row-lock a tryout while a registration slot is decided.
PostgreSQL serializes concurrent registration attempts on this row. The
duplicate check, capacity count and insert that follow therefore form
one decision instead of three independently racing statements. SQLite
ignores ``FOR UPDATE`` in tests, but production does not.
"""
tryout = db.session.execute(registration_lock_statement(tryout_id)).scalar_one_or_none()
if tryout is None:
abort(404)
return tryout
@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=utc_now_naive())
@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 = db.get_or_404(Tryout, 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 = db.get_or_404(Tryout, 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=utc_now_naive(),
)
@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."""
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))
tryout = locked_tryout_or_404(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 = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
try:
data = TryoutStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
tryout.status = data['status']
db.session.commit()
flash(_('Tryout status updated to %(new_status)s.', new_status=data['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 = db.get_or_404(Tryout, 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()
try:
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration.status = data['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 = locked_tryout_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 = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
player = db.get_or_404(User, 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 = db.get_or_404(Tryout, 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 = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(_('Team "%(team_name)s" created!', team_name=data['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 = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, 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)
try:
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = data['player_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))
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=data['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 = db.get_or_404(Tryout, 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'))
-1270
View File
File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
"""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',
]
-89
View File
@@ -1,89 +0,0 @@
"""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_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 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 from validated dynamic form fields."""
from app.forms import form_gamertags
submitted = form_gamertags(selected_games)
existing_gamertags = {gt.game: gt for gt in user.gamertags}
for game in selected_games:
payload = submitted.get(game)
existing = existing_gamertags.get(game)
if payload:
if existing:
existing.gamertag = payload['gamertag']
existing.platform = payload['platform']
else:
gt = UserGamertag(
user_id=user.id,
game=game,
gamertag=payload['gamertag'],
platform=payload['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])
-383
View File
@@ -1,383 +0,0 @@
"""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 = db.get_or_404(User, 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()
try:
update_user_gamertags(user, selected_games)
except ValidationError as err:
flash_validation_errors(err)
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
# 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 = db.get_or_404(User, 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 = db.get_or_404(User, user_id)
return render_template('pages/view_user.html', profile_user=user)
-270
View File
@@ -1,270 +0,0 @@
"""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 = db.get_or_404(PlayerDisponibility, 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
@@ -1,16 +0,0 @@
"""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
@@ -1,211 +0,0 @@
"""Player contracts: upload, sign, download."""
import os
import uuid
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.time_utils import utc_now_naive
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 = db.get_or_404(User, 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 = db.get_or_404(Contract, 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 = utc_now_naive()
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 = db.get_or_404(Contract, 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 = db.get_or_404(Contract, 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,
)
-437
View File
@@ -1,437 +0,0 @@
"""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 marshmallow import ValidationError
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Coach,
Match,
MatchParticipant,
OneOnOneRequest,
PersonalNote,
Player,
Team,
TeamMember,
TeamNote,
Tryout,
TryoutRegistration,
User,
)
from app.permissions import (
coach_can_access_player,
coach_org_teams,
coach_player_ids,
coach_tryouts,
)
from app.routes.users.blueprint import users_bp
from app.validators import NoteContentSchema, PersonalNoteSchema
@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
# PersonalNote.team_id references a tryout-local Team, not OrgTeam. The
# previous selector mixed the two namespaces and could either attach the
# note to an unrelated team with the same integer id or fail its FK.
# Every context list now comes from the tryouts this coach may manage.
tryouts = list(reversed(coach_tryouts(current_user)))[:20]
tryout_ids = [tryout.id for tryout in tryouts]
matches = (
Match.query.filter(Match.tryout_id.in_(tryout_ids))
.order_by(Match.date.desc())
.limit(20)
.all()
if tryout_ids
else []
)
teams = (
Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
if tryout_ids
else []
)
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]
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
note = TeamNote(
org_team_id=org_team.id,
coach_id=current_user.id,
content=data['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'))
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = db.get_or_404(User, 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=data['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'))
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
player_id = data['player_id']
player = db.get_or_404(User, 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'))
if data['match_id']:
match = db.get_or_404(Match, data['match_id'])
if not current_user.can_manage_this_tryout(match.tryout):
flash(_('You cannot use that match as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first():
flash(_('That player did not participate in the selected match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['tryout_id']:
tryout = db.get_or_404(Tryout, data['tryout_id'])
if not current_user.can_manage_this_tryout(tryout):
flash(_('You cannot use that tryout as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first():
flash(_('That player is not registered for the selected tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if data['team_id']:
team = db.get_or_404(Team, data['team_id'])
if not current_user.can_manage_this_tryout(team.tryout):
flash(_('You cannot use that team as note context.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first():
flash(_('That player is not on the selected team.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
note = PersonalNote(
player_id=player_id,
coach_id=current_user.id,
content=data['content'],
match_id=data['match_id'],
tryout_id=data['tryout_id'],
team_id=data['team_id'],
)
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 = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to add notes for this tryout.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
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':
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
player_id = data['player_id']
if data['tryout_id'] not in (None, tryout_id):
flash(_('Invalid tryout context.'), '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))
if not TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first():
flash(_('That player is not registered for this tryout.'), '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=data['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 = db.get_or_404(Match, match_id)
if not current_user.can_manage_this_tryout(match_obj.tryout):
flash(_('You do not have permission to add notes for this match.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
# 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':
try:
data = PersonalNoteSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.add_note_from_match', match_id=match_id))
player_id = data['player_id']
if data['match_id'] not in (None, match_id):
flash(_('Invalid match context.'), '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))
if not MatchParticipant.query.filter_by(match_id=match_id, player_id=player_id).first():
flash(_('That player did not participate in this match.'), '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=data['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=[],
)
-267
View File
@@ -1,267 +0,0 @@
"""One-on-one sessions between a player and their coach."""
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.time_utils import utc_now_naive
from app.validators import OneOnOneRejectionSchema, 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 = db.get_or_404(OneOnOneRequest, 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 = utc_now_naive()
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 = db.get_or_404(OneOnOneRequest, 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'))
try:
data = OneOnOneRejectionSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('users.notes_dashboard'))
rejection_reason = data['rejection_reason']
player = request_obj.player
request_obj.status = 'rejected'
request_obj.responded_at = utc_now_naive()
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'))
-140
View File
@@ -1,140 +0,0 @@
"""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(),
)
try:
update_user_gamertags(current_user, selected_games)
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(),
)
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
# 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
@@ -1 +0,0 @@
"""Business services: work that is neither a route nor a model."""
-127
View File
@@ -1,127 +0,0 @@
"""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
@@ -1,96 +0,0 @@
"""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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

-149
View File
@@ -1,149 +0,0 @@
"""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
@@ -1 +0,0 @@
# supporting scripts package
-380
View File
@@ -1,380 +0,0 @@
"""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. A missing or unreadable document store
# is now a failed full-backup run rather than a database-only green result.
#
# 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.
Raises:
BackupError: If the configured store is absent or cannot be archived.
Signed contracts live only on disk, so a database-only run must
never be reported as a complete backup.
"""
documents_dir = documents_root()
if not os.path.exists(documents_dir):
raise BackupError(f'Documents directory does not exist: {documents_dir}')
if not os.path.isdir(documents_dir):
raise BackupError(f'Documents path is not a directory: {documents_dir}')
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 — normalize the shutil boundary
raise BackupError(f'Document backup failed: {exc}') from exc
zip_path = f'{archive_basename}.zip'
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
raise BackupError('Document archiver reported success but produced an empty file.')
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)
documents_ok = True
try:
backup_documents()
except BackupError as exc:
documents_ok = False
print(f'[ERROR] {exc}')
cleanup_old_backups()
print()
if verified and documents_ok:
print('=== Backup completed successfully ===')
return 0
print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===')
return 1
if __name__ == '__main__':
sys.exit(main())
-443
View File
@@ -1,443 +0,0 @@
"""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())
-15
View File
@@ -1,15 +0,0 @@
{% 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
@@ -1,15 +0,0 @@
{% 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
@@ -1,15 +0,0 @@
{% 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
@@ -1,15 +0,0 @@
{% 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
@@ -1,21 +0,0 @@
{% 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 %}
@@ -1,19 +0,0 @@
{# 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
@@ -1,41 +0,0 @@
{#
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 %}
-85
View File
@@ -1,85 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}Evaluate Players - UdeS team manager{% endblock %}
{% block page_title %}Evaluate Players{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
{% block content %}
<form method="POST" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% for entry in players %}
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
{% endfor %}
{% set positions = game_positions.get(tryout.game, []) %}
<div class="batch-eval-grid">
{% for entry in players %}
<div class="card">
<div class="card-header">
<h3>
<i class="fas fa-user"></i> {{ entry.player.username }}
{% if entry.existing %}
<span class="badge badge-success">Already Evaluated</span>
{% else %}
<span class="badge badge-warning">Not Evaluated</span>
{% endif %}
</h3>
</div>
<div class="card-body">
<div class="eval-player-info mb-4">
<div class="user-avatar avatar-lg">{{ entry.player.username[:2] | upper }}</div>
<div>
<h3>{{ entry.player.username }}</h3>
<p class="text-muted">{{ entry.player.email }} | {{ entry.player.phone or 'No phone' }}</p>
</div>
</div>
{% set pid = entry.player.id %}
{% set existing = entry.existing %}
{% set existing_scores = entry.existing_scores %}
<div class="form-row">
{% for field_name, label in evaluation_criteria %}
<div class="form-group col-4">
<label for="{{ field_name }}_{{ pid }}">{{ label }} (1-10)</label>
<div class="score-input">
<input type="range" id="{{ field_name }}_{{ pid }}" name="{{ field_name }}_{{ pid }}" min="1" max="10" value="{{ existing_scores[field_name] or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<span class="range-value">{{ existing_scores[field_name] or 5 }}</span>
</div>
</div>
{% endfor %}
</div>
<div class="form-row">
<div class="form-group col-12">
<label for="position_recommendation_{{ pid }}">Recommended Position</label>
{% if positions %}
<select id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" class="form-select">
<option value="">-- Select Position --</option>
{% for pos in positions %}
<option value="{{ pos }}" {% if existing and existing.position_recommendation == pos %}selected{% endif %}>{{ pos }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" value="{{ existing.position_recommendation if existing else '' }}" placeholder="Enter position (optional)">
{% endif %}
</div>
</div>
<div class="form-group">
<label for="comments_{{ pid }}">Comments</label>
<textarea id="comments_{{ pid }}" name="comments_{{ pid }}" rows="3" placeholder="Enter your evaluation notes...">{{ existing.comments if existing else '' }}</textarea>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="form-actions">
<a href="{{ url_for('evaluations.players_to_evaluate', tryout_id=tryout.id) }}" class="btn btn-secondary">Back</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save All Evaluations
</button>
</div>
</form>
{% endblock %}
-462
View File
@@ -1,462 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('Calendar') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Calendar') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Calendar</span>{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-calendar-alt"></i> {{ _('Schedule') }}</h3>
<div class="header-actions">
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="dayGridMonth">
<i class="fas fa-calendar"></i> {{ _('Month') }}
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridWeek">
<i class="fas fa-calendar-week"></i> {{ _('Week') }}
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridDay">
<i class="fas fa-calendar-day"></i> {{ _('Day') }}
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="listMonth">
<i class="fas fa-list"></i> {{ _('List') }}
</button>
</div>
</div>
</div>
<div class="card-body">
<div id="calendar"></div>
</div>
</div>
<!-- Create Event Modal (for clicking empty days) -->
<div id="createEventModal" class="modal hidden">
<div class="modal-backdrop" data-action="hide-create-event"></div>
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-plus-circle"></i> {{ _('Create New Event') }}</h3>
<button class="modal-close" data-action="hide-create-event">&times;</button>
</div>
<div class="modal-body">
<p class="mb-3"><strong>{{ _('Date:') }}</strong> <span id="createEventDate"></span></p>
<input type="hidden" id="createEventDateInput"/>
<div class="card mb-3">
<div class="card-body">
<h5><i class="fas fa-futbol"></i> {{ _('Schedule Tryout Match') }}</h5>
<p class="text-muted small">{{ _('Add a scrim/match inside an existing tryout') }}</p>
<div class="form-inline">
<select id="createTryoutSelect" class="form-select" style="flex:1;">
<option value="">{{ _('-- Select a tryout --') }}</option>
</select>
<button class="btn btn-sm btn-primary ml-2" data-action="go-tryout-match">
<i class="fas fa-arrow-right"></i> {{ _('Go') }}
</button>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5><i class="fas fa-users"></i> {{ _('Schedule Team Match') }}</h5>
<p class="text-muted small">{{ _('Regular season match for an org team') }}</p>
<div class="form-inline">
<select id="createTeamSelect" class="form-select" style="flex:1;">
<option value="">{{ _('-- Select a team --') }}</option>
</select>
<button class="btn btn-sm btn-success ml-2" data-action="go-team-match">
<i class="fas fa-arrow-right"></i> {{ _('Go') }}
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<h5><i class="fas fa-calendar-plus"></i> {{ _('Create New Tryout') }}</h5>
<p class="text-muted small">{{ _('Create a brand new tryout event') }}</p>
<button class="btn btn-sm btn-info" data-action="go-create-tryout">
<i class="fas fa-plus"></i> {{ _('Create Tryout') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Event Details Modal -->
<div id="eventModal" class="modal hidden">
<div class="modal-backdrop" data-action="hide-event-modal"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">{{ _('Event Details') }}</h3>
<button class="modal-close" data-action="hide-event-modal">&times;</button>
</div>
<div class="modal-body">
<div id="modalContent"></div>
<div id="modalPresenceToggle" class="form-actions mt-3" style="display: none; justify-content: center;">
<button class="btn btn-sm btn-outline" id="calPresenceBtn">{{ _('Confirm') }}</button>
</div>
<div id="modalActions" class="form-actions mt-3" style="display: none;">
<button class="btn btn-sm btn-danger" id="deleteMatchBtn" style="display: none;">
<i class="fas fa-trash"></i> {{ _('Delete Match') }}
</button>
<button class="btn btn-sm btn-primary" id="editMatchBtn" style="display: none;">
<i class="fas fa-edit"></i> {{ _('Edit Match') }}
</button>
<button class="btn btn-sm btn-primary" id="viewTryoutBtn" style="display: none;">
<i class="fas fa-eye"></i> {{ _('View Tryout') }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{# The stylesheet that used to sit here — index.global.min.css — does not
exist. FullCalendar 6 bundles its styles into the JS, and that file is not
in the published package: the link had been answering 404 on every calendar
load since the upgrade. A failed stylesheet is silent in the browser, which
is why it survived.
Integrity pins the bundle (QUA-004): this file is executable script from a
third party, on the page that shows every match in the club. See the note
in layouts/base.html for what SRI does and does not promise. #}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.js"
integrity="sha384-WfE/vOHqht3KDj6FvpwQUf3UxEPUHoGJ3w1yZ8rhpLWnVigt8HjXL2zXqtcfS7mf"
crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
<script nonce="{{ csp_nonce }}">
var canScheduleMatches = {% if current_user.can_schedule_matches() %}true{% else %}false{% endif %};
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
},
events: '/matches/api/events',
eventClick: function(info) {
showEventModal(info.event);
},
dateClick: function(info) {
if (canScheduleMatches) {
var dateStr = info.dateStr;
showCreateEventModal(dateStr);
}
},
selectable: true,
select: function(info) {
if (canScheduleMatches) {
var dateStr = info.startStr;
showCreateEventModal(dateStr);
calendar.unselect();
}
},
slotMinTime: '12:00:00',
slotMaxTime: '24:00:00'
});
calendar.render();
window.fcCalendar = calendar;
// Pre-load tryout and team options for the create modal
if (canScheduleMatches) {
fetchTryoutOptions();
fetchTeamOptions();
}
});
function changeView(viewName) {
if (window.fcCalendar) {
window.fcCalendar.changeView(viewName);
}
}
// --- Create Event Modal ---
function showCreateEventModal(dateStr) {
document.getElementById('createEventDate').textContent = dateStr;
document.getElementById('createEventDateInput').value = dateStr;
// Reset dropdowns
document.getElementById('createTryoutSelect').value = '';
document.getElementById('createTeamSelect').value = '';
document.getElementById('createEventModal').classList.remove('hidden');
}
function hideCreateEventModal() {
document.getElementById('createEventModal').classList.add('hidden');
}
function goToTryoutMatch() {
var tryoutId = document.getElementById('createTryoutSelect').value;
if (!tryoutId) { alert('Please select a tryout.'); return; }
var date = document.getElementById('createEventDateInput').value;
window.location.href = '/matches/create/' + tryoutId + '?date=' + date;
}
function goToTeamMatch() {
var teamId = document.getElementById('createTeamSelect').value;
if (!teamId) { alert('Please select a team.'); return; }
var date = document.getElementById('createEventDateInput').value;
window.location.href = '/team-matches/' + teamId + '/create?date=' + date;
}
function goToCreateTryout() {
window.location.href = '/tryouts/create';
}
// Pre-fetch data for dropdowns
function fetchTryoutOptions() {
fetch('/matches/api/manageable-tryouts')
.then(function(r) { return r.json(); })
.then(function(data) {
// new Option() sets the label as text; concatenating it into
// innerHTML let a tryout title carry markup into the page.
var sel = document.getElementById('createTryoutSelect');
sel.replaceChildren(new Option('-- Select a tryout --', ''));
var today = new Date().toISOString().split('T')[0];
data.forEach(function(t) {
// Only show tryouts that haven't ended
var tryoutEndDate = t.end_date || t.date;
if (tryoutEndDate >= today) {
sel.appendChild(new Option(t.title + ' (' + t.date + ')', t.id));
}
});
})
.catch(function() {});
}
function fetchTeamOptions() {
fetch('/team-matches/api/manageable-teams')
.then(function(r) { return r.json(); })
.then(function(data) {
var sel = document.getElementById('createTeamSelect');
sel.replaceChildren(new Option('-- Select a team --', ''));
data.forEach(function(t) {
sel.appendChild(new Option(t.name, t.id));
});
})
.catch(function() {});
}
// ---------------------------------------------------------------------------
// Safe DOM builders
// ---------------------------------------------------------------------------
// Everything rendered in the event modal originates from a JSON endpoint,
// where no HTML escaping applies. Text therefore goes through textContent,
// never through innerHTML.
function makeEl(tag, className, text) {
var node = document.createElement(tag);
if (className) { node.className = className; }
if (text !== undefined && text !== null) { node.textContent = text; }
return node;
}
function detailItem(label, valueNode, fullWidth) {
var item = makeEl('div', 'detail-item' + (fullWidth ? ' full-width' : ''));
item.appendChild(makeEl('span', 'detail-label', label));
var value = makeEl('span', 'detail-value');
value.appendChild(valueNode);
item.appendChild(value);
return item;
}
// Renders newlines as <br> without letting any other markup through.
function multilineNode(text) {
var fragment = document.createDocumentFragment();
String(text).split('\n').forEach(function(line, index) {
if (index > 0) { fragment.appendChild(document.createElement('br')); }
fragment.appendChild(document.createTextNode(line));
});
return fragment;
}
// A team block: its name, plus an optional list of player names.
function teamNode(name, players) {
var team = makeEl('div', 'match-team');
team.appendChild(makeEl('span', 'team-name', name));
if (players) {
var list = makeEl('ul', 'team-players-list');
players.forEach(function(player) {
list.appendChild(makeEl('li', null, player));
});
team.appendChild(list);
}
return team;
}
function versusNode(left, right) {
var wrap = makeEl('div', 'match-teams');
wrap.appendChild(left);
wrap.appendChild(makeEl('div', 'match-vs', 'vs'));
wrap.appendChild(right);
return wrap;
}
function buildTeamsNode(props) {
var sides = props.participants.split(' vs ');
if (props.match_type === 'team_vs_team' && sides.length >= 2) {
return versusNode(teamNode(sides[0]), teamNode(sides[1]));
}
if (props.match_type === 'player_vs_player' && sides.length >= 2) {
return versusNode(
teamNode('Team 1', sides[0].split(', ')),
teamNode('Team 2', sides[1].split(', '))
);
}
return document.createTextNode(props.participants);
}
function showEventModal(event) {
var props = event.extendedProps;
var title = event.title;
var type = props.type;
var date = event.start ? event.start.toDateString() : '';
// Built as DOM nodes rather than concatenated HTML. Every value below —
// match title, location, description, and above all the participant list,
// which is made of user-chosen usernames — comes from a JSON API and has
// never been HTML-escaped. Assigning it to innerHTML executed it.
var grid = makeEl('div', 'detail-grid');
var typeLabel = props.match_type === 'team_vs_team' ? 'Team Match'
: (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim');
var typeBadgeClass = (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player')
? 'success' : 'warning';
grid.appendChild(detailItem('Type', makeEl('span', 'badge badge-' + typeBadgeClass, typeLabel)));
grid.appendChild(detailItem('Title', document.createTextNode(title)));
grid.appendChild(detailItem('Date', document.createTextNode(date)));
grid.appendChild(detailItem('Location', document.createTextNode(props.location || 'TBD')));
var status = props.status || 'scheduled';
grid.appendChild(detailItem('Status', makeEl('span', 'badge badge-' + status, status)));
if (type === 'match' && props.participants) {
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
}
if (props.description) {
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
}
var modalContent = document.getElementById('modalTitle');
modalContent.textContent = 'Match Details';
var target = document.getElementById('modalContent');
target.textContent = '';
target.appendChild(grid);
// Reset buttons
document.getElementById('deleteMatchBtn').style.display = 'none';
document.getElementById('editMatchBtn').style.display = 'none';
document.getElementById('viewTryoutBtn').style.display = 'none';
// Show action buttons for matches (coaches and above)
if (type === 'match' && canScheduleMatches) {
document.getElementById('modalActions').style.display = 'flex';
document.getElementById('editMatchBtn').style.display = 'inline-flex';
document.getElementById('editMatchBtn').onclick = function() {
window.location.href = '/matches/' + props.match_id + '/edit';
};
document.getElementById('deleteMatchBtn').style.display = 'inline-flex';
document.getElementById('deleteMatchBtn').onclick = function() {
if (confirm('Are you sure you want to delete this match?')) {
deleteCalendarMatch(props.match_id);
}
};
} else {
document.getElementById('modalActions').style.display = 'none';
}
// Show presence toggle for matches where user is a participant
var presenceDiv = document.getElementById('modalPresenceToggle');
if (type === 'match' && props.user_participant_id) {
presenceDiv.style.display = 'flex';
var confirmed = props.user_attendance_confirmed || false;
var toggleBtn = document.getElementById('calPresenceBtn');
toggleBtn.textContent = confirmed ? '✅ Confirmed' : 'Confirm';
toggleBtn.className = 'btn btn-sm ' + (confirmed ? 'btn-success' : 'btn-outline');
toggleBtn.onclick = function() {
toggleCalendarPresence(props.match_id, props.user_participant_id, toggleBtn);
};
} else {
presenceDiv.style.display = 'none';
}
document.getElementById('eventModal').classList.remove('hidden');
}
function deleteCalendarMatch(matchId) {
fetch('/matches/' + matchId + '/delete', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(r) { return r.json().catch(function() { return {}; }); })
.then(function() {
hideEventModal();
if (window.fcCalendar) {
window.fcCalendar.refetchEvents();
}
})
.catch(function(err) {
console.error('Error deleting match:', err);
alert('Failed to delete match.');
});
}
function toggleCalendarPresence(matchId, participantId, btn) {
fetch('/matches/' + matchId + '/toggle-presence/' + participantId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.attendance_confirmed) {
btn.classList.add('btn-success');
btn.classList.remove('btn-outline');
btn.textContent = '✅ Confirmed';
} else {
btn.classList.remove('btn-success');
btn.classList.add('btn-outline');
btn.textContent = 'Confirm';
}
})
.catch(function(err) {
console.error('Error toggling presence:', err);
});
}
function hideEventModal() {
document.getElementById('eventModal').classList.add('hidden');
}
// Behaviours declared in the markup, dispatched by the delegated listener
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
registerActions({
'change-view': function (element) {
changeView(element.getAttribute('data-view'));
},
'hide-create-event': hideCreateEventModal,
'go-tryout-match': goToTryoutMatch,
'go-team-match': goToTeamMatch,
'go-create-tryout': goToCreateTryout,
'hide-event-modal': hideEventModal,
});
</script>
{% endblock %}
-150
View File
@@ -1,150 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('Edit Profile') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Edit Profile') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Edit</span>{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<form method="POST" action="{{ url_for('users.edit_profile') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row">
<div class="form-group col-6">
<label for="username">{{ _('Username') }}</label>
<input type="text" id="username" name="username" value="{{ user.username }}" required>
</div>
<div class="form-group col-6">
<label for="full_name">{{ _('Full Name') }}</label>
<input type="text" id="full_name" name="full_name" value="{{ user.full_name }}" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-6">
<label for="email">{{ _('Email') }}</label>
<input type="email" id="email" name="email" value="{{ user.email }}" required>
</div>
<div class="form-group col-6">
<label for="phone">{{ _('Phone') }}</label>
<input type="tel" id="phone" name="phone" value="{{ user.phone or '' }}">
</div>
</div>
<hr class="section-divider">
<h4 class="section-title"><i class="fas fa-gamepad"></i> {{ _('E-Sports Profile') }}</h4>
<p class="text-muted small">{{ _('Update your competitive gaming profile for tryouts.') }}</p>
<div class="form-group">
<label><i class="fas fa-headset"></i> {{ _('Games You Play') }}</label>
<div class="checkbox-grid">
{% for game in esport_games %}
{% set games_list = user.get_games_list() %}
<label class="checkbox-label">
<input type="checkbox" name="games" value="{{ game }}" {% if game in games_list %}checked{% endif %}>
<span>{{ game }}</span>
</label>
{% endfor %}
</div>
<small class="form-text text-muted">{{ _('Select all games you\'re signing in for.') }}</small>
</div>
<!-- Gamertag inputs - will be shown when game is selected -->
<div id="gamertag-section" style="display: none;">
<hr class="section-divider">
<h4 class="section-title"><i class="fas fa-chart-line"></i> {{ _('Gamertags for TRN') }}</h4>
<p class="text-muted small">{{ _('Enter your gamertag for each selected game to link to your Tracker Network profile.') }}</p>
{% for game in esport_games %}
{% set gamertag_data = user_gamertags.get(game) %}
<div class="gamertag-input-row" data-game="{{ game }}" style="display: none;">
<h5>{{ game }}</h5>
<div class="form-row">
<div class="form-group col-6">
<label for="gamertag_{{ game }}">{{ _('Gamertag') }}</label>
<input type="text" id="gamertag_{{ game }}" name="gamertag_{{ game }}" value="{{ gamertag_data.gamertag if gamertag_data else '' }}" placeholder="Your {{ game }} gamertag">
</div>
{% if game_platforms.get(game) %}
<div class="form-group col-6">
<label for="platform_{{ game }}">{{ _('Platform') }}</label>
<select id="platform_{{ game }}" name="platform_{{ game }}">
<option value="">{{ _('Select Platform') }}</option>
{% for platform in game_platforms[game] %}
<option value="{{ platform }}" {% if gamertag_data and gamertag_data.platform == platform %}selected{% endif %}>{{ platform }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<div class="form-row">
<div class="form-group col-12">
<label for="discord_username"><i class="fab fa-discord"></i> {{ _('Discord Username') }}</label>
<input type="text" id="discord_username" name="discord_username" value="{{ user.discord_username or '' }}" placeholder="{{ _('e.g. Name#1234') }}">
<a href="{{ url_for('auth.discord_login') }}" class="btn btn-secondary mt-2">
<i class="fab fa-discord"></i>
{% if user.discord_user_id %}{{ _('Reconnect') }}{% else %}{{ _('Connect Discord Account') }}{% endif %}
</a>
</div>
</div>
<div class="form-row">
<div class="form-group col-6">
<label for="league_os_profile"><i class="fas fa-link"></i> {{ _('League OS Connection') }}</label>
<input type="text" id="league_os_profile" name="league_os_profile" value="{{ user.league_os_profile or '' }}" placeholder="{{ _('League OS profile link or ID') }}">
</div>
</div>
<hr class="section-divider">
<h4 class="section-title"><i class="fas fa-lock"></i> {{ _('Change Password') }}</h4>
<p class="text-muted small">{{ _('Leave blank to keep your current password.') }}</p>
<div class="form-group">
<label for="password">{{ _('New Password') }}</label>
<input type="password" id="password" name="password" placeholder="{{ _('Enter new password') }}">
</div>
<div class="form-actions">
<a href="{{ url_for('users.profile') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">{{ _('Save Changes') }}</button>
</div>
</form>
</div>
</div>
<script nonce="{{ csp_nonce }}">
var GAME_PLATFORMS = {{ game_platforms|tojson }};
// Show/hide gamertag inputs when games are checked
function toggleGamertagInputs() {
var selectedGames = [];
document.querySelectorAll('input[name="games"]:checked').forEach(function(cb) {
selectedGames.push(cb.value);
});
if (selectedGames.length > 0) {
document.getElementById('gamertag-section').style.display = 'block';
} else {
document.getElementById('gamertag-section').style.display = 'none';
}
document.querySelectorAll('.gamertag-input-row').forEach(function(row) {
if (selectedGames.includes(row.dataset.game)) {
row.style.display = 'block';
} else {
row.style.display = 'none';
}
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Set up event listeners for game checkboxes
document.querySelectorAll('input[name="games"]').forEach(function(cb) {
cb.addEventListener('change', toggleGamertagInputs);
});
// Show gamertag inputs for already selected games
toggleGamertagInputs();
});
</script>
{% endblock %}
-17
View File
@@ -1,17 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('Login') }} - UdeS team manager{% endblock %}
{% block auth_content %}
<form method="POST" action="{{ url_for('auth.login') }}" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="username"><i class="fas fa-user"></i> {{ _('Username') }}</label>
<input type="text" id="username" name="username" placeholder="{{ _('Enter your username') }}" required>
</div>
<div class="form-group">
<label for="password"><i class="fas fa-lock"></i> {{ _('Password') }}</label>
<input type="password" id="password" name="password" placeholder="{{ _('Enter your password') }}" required>
</div>
<button type="submit" class="btn btn-primary btn-block">{{ _('Sign In') }}</button>
<p class="auth-link">{{ _("Don't have an account?") }} <a href="{{ url_for('auth.register') }}">{{ _('Register here') }}</a></p>
</form>
{% endblock %}
-285
View File
@@ -1,285 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('My Team(s)') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('My Team(s)') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / My Team(s)</span>{% endblock %}
{% block content %}
{% if team_data %}
{% for item in team_data %}
{% set team = item.team %}
<div class="card mb-4">
<div class="card-header">
<h3><i class="fas fa-users"></i> {{ team.name }}</h3>
</div>
<!-- Staff bar -->
<div class="team-staff-bar">
<div class="staff-row">
<div class="staff-group">
<span class="staff-label"><i class="fas fa-chalkboard-teacher"></i> {{ _('Coaches') }}</span>
<div class="staff-items">
{% if item.coaches %}
{% for c in item.coaches %}
<span class="staff-tag">{{ c.username }}</span>
{% endfor %}
{% else %}
<span class="text-muted text-sm">None</span>
{% endif %}
</div>
</div>
<div class="staff-group">
<span class="staff-label"><i class="fas fa-user-tie"></i> {{ _('Managers') }}</span>
<div class="staff-items">
{% if item.managers %}
{% for m in item.managers %}
<span class="staff-tag manager-tag">{{ m.username }}</span>
{% endfor %}
{% else %}
<span class="text-muted text-sm">None</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card-body">
<!-- Team Roster -->
<h4 class="mb-3"><i class="fas fa-users"></i> {{ _('Team Roster') }}</h4>
{% set roster = team.get_players_with_status() %}
{% if roster %}
<div class="table-container mb-4">
<table class="table">
<thead>
<tr>
<th>{{ _('Player') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Position') }}</th>
</tr>
</thead>
<tbody>
{% for entry in roster %}
<tr>
<td>
<div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
</div>
</td>
<td>
<span class="badge {% if entry.status == 'starter' %}badge-success{% else %}badge-warning{% endif %}">
{{ entry.status | capitalize }}
</span>
</td>
<td>{{ entry.position or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-3 text-muted">
<i class="fas fa-users-slash"></i> {{ _('No players on this team.') }}
</div>
{% endif %}
<!-- Team Matches -->
<h4 class="mb-3"><i class="fas fa-futbol"></i> {{ _('Upcoming Matches') }}</h4>
{% if item.matches %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Match') }}</th>
<th>{{ _('Opponent') }}</th>
<th>{{ _('Date') }}</th>
<th>{{ _('Time') }}</th>
<th>{{ _('Location') }}</th>
<th>{{ _('Presence') }}</th>
<th>{{ _('My Status') }}</th>
</tr>
</thead>
<tbody>
{% for mdata in item.matches %}
{% set tm = mdata.match %}
<tr>
<td class="cell-title">{{ tm.title }}</td>
<td>
{% if tm.opponent %}
{{ tm.opponent }}
{% else %}
<span class="badge badge-info">Practice</span>
{% endif %}
</td>
<td>{{ tm.date.strftime('%m/%d/%Y') }}</td>
<td>
{% if tm.start_time and tm.end_time %}
{{ tm.start_time.strftime('%H:%M') }} - {{ tm.end_time.strftime('%H:%M') }}
{% else %}
TBD
{% endif %}
</td>
<td>{{ tm.location or '—' }}</td>
<td>
{% if mdata.total_count > 0 %}
<span title="{{ mdata.confirmed_count }} of {{ mdata.total_count }} confirmed">
{% if mdata.confirmed_count == mdata.total_count and mdata.total_count > 0 %}
✅ {{ mdata.confirmed_count }}/{{ mdata.total_count }}
{% elif mdata.confirmed_count > 0 %}
⏳ {{ mdata.confirmed_count }}/{{ mdata.total_count }}
{% else %}
❌ 0/{{ mdata.total_count }}
{% endif %}
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if mdata.participant_id %}
<button class="btn btn-sm {% if mdata.is_confirmed %}btn-success{% else %}btn-outline{% endif %} presence-toggle-btn"
data-match-id="{{ tm.id }}"
data-participant-id="{{ mdata.participant_id }}"
data-action="toggle-presence">
{% if mdata.is_confirmed %}✅ Confirmed{% else %}Confirm{% endif %}
</button>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-3 text-muted">
<i class="fas fa-calendar-alt"></i> {{ _('No upcoming matches scheduled.') }}
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<div class="empty-state">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h3>{{ _('No Teams') }}</h3>
<p class="text-muted">{{ _('You are not currently assigned to any team.') }}</p>
</div>
</div>
</div>
{% endif %}
<script nonce="{{ csp_nonce }}">
function togglePresence(btn) {
var matchId = btn.getAttribute('data-match-id');
var participantId = btn.getAttribute('data-participant-id');
fetch('/team-matches/' + matchId + '/toggle-presence/' + participantId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.is_confirmed) {
btn.classList.add('btn-success');
btn.classList.remove('btn-outline');
btn.innerHTML = '✅ Confirmed';
} else {
btn.classList.remove('btn-success');
btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm';
}
location.reload();
})
.catch(function(error) {
console.error('Error:', error);
});
}
// Behaviours are declared in the markup with data-action / data-change and
// dispatched by the delegated listener in main.js. This replaces inline
// onclick attributes, which no CSP nonce is able to authorise.
registerActions({
'toggle-presence': togglePresence,
});
</script>
<style>
.team-staff-bar {
padding: 10px 20px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.staff-row {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.staff-group {
display: flex;
align-items: center;
gap: 8px;
}
.staff-label {
font-size: 0.8rem;
color: #64748b;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.staff-label i {
margin-right: 3px;
font-size: 0.75rem;
}
.staff-items {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.staff-tag {
display: inline-flex;
align-items: center;
gap: 5px;
background: #e0e7ff;
color: #3730a3;
padding: 3px 10px;
border-radius: 14px;
font-size: 0.82rem;
font-weight: 500;
line-height: 1.3;
}
.staff-tag.manager-tag {
background: #fef3c7;
color: #92400e;
}
.text-sm {
font-size: 0.82rem;
}
.presence-toggle-btn {
min-width: 100px;
}
/* Dark mode */
[data-theme="dark"] .team-staff-bar {
background: var(--bg-tertiary);
border-bottom-color: var(--border-color);
}
[data-theme="dark"] .staff-label {
color: var(--text-muted);
}
[data-theme="dark"] .staff-tag {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
}
[data-theme="dark"] .staff-tag.manager-tag {
background: rgba(229, 169, 57, 0.2);
color: #fcd34d;
}
</style>
{% endblock %}
-286
View File
@@ -1,286 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}{{ _('Notes') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Notes') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Notes</span>{% endblock %}
{% block content %}
<div class="dashboard-grid">
<!-- Add Team Notes Section -->
{% if org_team %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-users"></i> {{ _('Team Notes') }}</h3>
<span class="badge badge-esport">{{ org_team.name }}</span>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('users.manage_team_notes') }}" class="form" id="teamNotesForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="team_notes_content">{{ _('Team Notes Content') }}</label>
<textarea name="content" id="team_notes_content" class="form-textarea" rows="4" placeholder="{{ _('Enter improvement suggestions and notes for your team...') }}">{{ latest_team_note.content if latest_team_note else '' }}</textarea>
<p class="form-text">{{ _('These notes will be visible to all players on your team.') }}</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> {% if latest_team_note %}Update{% else %}Add{% endif %} Team Notes
</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Add Personal Note Section -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-user-friends"></i> {{ _('Add Personal Note') }}</h3>
{% if org_team %}
<span class="badge badge-esport">{{ org_team.name }}</span>
{% endif %}
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('users.add_personal_note') }}" class="form" id="personalNoteForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="player_id">{{ _('Select Player') }}</label>
<select name="player_id" id="player_id" class="form-select" required>
<option value="">{{ _('-- Select a Player --') }}</option>
{% for player in players %}
<option value="{{ player.id }}">{{ player.username }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="note_content">{{ _('Note Content') }}</label>
<textarea name="content" id="note_content" class="form-textarea" rows="3" placeholder="{{ _('Enter personal feedback or coaching tips for this player...') }}" required></textarea>
<p class="form-text">{{ _('These notes will only be visible to the selected player.') }}</p>
</div>
<div class="form-group">
<label for="context">{{ _('Context (Optional)') }}</label>
<p class="form-text text-muted">{{ _('Link this note to a specific match, tryout, or team for better organization.') }}</p>
<div class="form-row">
<div class="form-group">
<label for="note_match_id">{{ _('Match') }}</label>
<select name="match_id" id="note_match_id" class="form-select">
<option value="">{{ _('-- Select Match --') }}</option>
{% for match in matches %}
<option value="{{ match.id }}">{{ match.title }} - {{ match.date.strftime('%m/%d/%Y') }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="note_tryout_id">{{ _('Tryout') }}</label>
<select name="tryout_id" id="note_tryout_id" class="form-select">
<option value="">{{ _('-- Select Tryout --') }}</option>
{% for tryout in tryouts %}
<option value="{{ tryout.id }}">{{ tryout.title }} - {{ tryout.date.strftime('%m/%d/%Y') }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="note_team_id">{{ _('Team') }}</label>
<select name="team_id" id="note_team_id" class="form-select">
<option value="">{{ _('-- Select Team --') }}</option>
{% for team in teams %}
<option value="{{ team.id }}">{{ team.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> {{ _('Add Note') }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- One on One Requests Section -->
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-calendar-check"></i> {{ _('One on One Requests') }}</h3>
{% if org_team %}
<span class="badge badge-esport">{{ org_team.name }}</span>
{% endif %}
</div>
<div class="card-body">
{% if one_on_one_requests %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>{{ _('Player') }}</th>
<th>{{ _('Date') }}</th>
<th>{{ _('Time') }}</th>
<th>{{ _('Discussion Points') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for req in one_on_one_requests %}
<tr>
<td>{{ req.player.username if req.player else 'Unknown' }}</td>
<td>{{ req.date.strftime('%b %d, %Y') }}</td>
<td>{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}</td>
<td>{{ req.points or 'N/A' }}</td>
<td>
{% if req.status == 'pending' %}
<span class="badge badge-warning">Pending</span>
{% elif req.status == 'approved' %}
<span class="badge badge-success">Approved</span>
{% elif req.status == 'rejected' %}
<span class="badge badge-danger">Rejected</span>
{% endif %}
</td>
<td>
{% if req.status == 'pending' %}
<form method="POST" action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-success" title="{{ _('Accept') }}">
<i class="fas fa-check"></i> {{ _('Accept') }}
</button>
</form>
<button type="button" class="btn btn-sm btn-danger" data-action="show-reject-modal" data-request-id="{{ req.id }}" title="{{ _('Refuse') }}">
<i class="fas fa-times"></i> {{ _('Refuse') }}
</button>
{% elif req.status == 'rejected' and req.coach_rejection_message %}
<span class="text-muted small" title="{{ req.coach_rejection_message }}">Reason: {{ req.coach_rejection_message[:50] }}{% if req.coach_rejection_message|length > 50 %}...{% endif %}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">{{ _('No One on One requests from your players yet.') }}</p>
{% endif %}
</div>
</div>
<!-- Reject Modal -->
<div id="rejectModal" class="modal" style="display:none;">
<div class="modal-overlay" data-action="hide-reject-modal"></div>
<div class="modal-content">
<div class="modal-header">
<h4><i class="fas fa-times-circle"></i> {{ _('Reject One on One Request') }}</h4>
<button type="button" class="modal-close" data-action="hide-reject-modal">&times;</button>
</div>
<form id="rejectForm" method="POST" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body">
<div class="form-group">
<label for="rejection_reason">{{ _('Reason for rejection (optional):') }}</label>
<textarea name="rejection_reason" id="rejection_reason" class="form-textarea" rows="3" placeholder="{{ _('Let the player know why this time doesn\'t work...') }}"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-action="hide-reject-modal">{{ _('Cancel') }}</button>
<button type="submit" class="btn btn-danger">{{ _('Reject Request') }}</button>
</div>
</form>
</div>
</div>
<div class="dashboard-grid mt-4">
<!-- Team Notes History -->
{% if org_team and team_notes %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-history"></i> {{ _('Team Notes History') }}</h3>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>{{ _('Last Updated') }}</th>
<th>{{ _('Content Preview') }}</th>
</tr>
</thead>
<tbody>
{% for note in team_notes %}
<tr>
<td>{{ note.updated_at.strftime('%B %d, %Y at %I:%M %p') if note.updated_at else 'Unknown date' }}</td>
<td>{{ note.content[:100] if note.content else '' }}{% if note.content and note.content|length > 100 %}...{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Personal Notes History -->
{% if personal_notes %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-sticky-note"></i> {{ _('Recent Personal Notes') }}</h3>
</div>
<div class="card-body">
<div class="detail-grid">
{% for note in personal_notes %}
<div class="detail-item full-width mb-4">
<span class="detail-label">
<i class="fas fa-user"></i> {{ note.player.username if note.player else 'Unknown Player' }} -
<span class="text-muted">{{ note.created_at.strftime('%B %d, %Y') if note.created_at else 'Unknown date' }}</span>
</span>
<span class="detail-value">{{ note.content | nl2br if note.content else '' }}</span>
<span class="detail-label text-muted small">From: {{ note.coach.username if note.coach else 'Unknown Coach' }}</span>
{% if note.match_id or note.team_id or note.tryout_id %}
<div class="mt-2">
{% if note.match_id and note.match %}
<span class="badge badge-info" title="{{ _('From match') }}"><i class="fas fa-futbol"></i> {{ note.match.title }}</span>
{% endif %}
{% if note.team_id and note.team %}
<span class="badge badge-warning" title="{{ _('From team') }}"><i class="fas fa-users"></i> {{ note.team.name }}</span>
{% endif %}
{% if note.tryout_id and note.tryout %}
<span class="badge badge-success" title="{{ _('From tryout') }}"><i class="fas fa-calendar-alt"></i> {{ note.tryout.title }}</span>
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script nonce="{{ csp_nonce }}">
function showRejectModal(requestId) {
const modal = document.getElementById('rejectModal');
const form = document.getElementById('rejectForm');
form.action = "{{ url_for('users.reject_one_on_one', request_id=0) }}".replace('0', requestId);
modal.style.display = 'flex';
}
function hideRejectModal() {
document.getElementById('rejectModal').style.display = 'none';
document.getElementById('rejection_reason').value = '';
}
// Behaviours are declared in the markup with data-action / data-change and
// dispatched by the delegated listener in main.js. This replaces inline
// onclick attributes, which no CSP nonce is able to authorise.
registerActions({
'show-reject-modal': function (element) {
showRejectModal(element.getAttribute('data-request-id'));
},
'hide-reject-modal': hideRejectModal,
});
</script>
{% endblock %}

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