This commit is contained in:
cedrick2711
2026-08-25 14:03:07 -04:00
191 changed files with 28790 additions and 5066 deletions
+45
View File
@@ -0,0 +1,45 @@
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
+117 -7
View File
@@ -6,16 +6,89 @@ on:
# branches: # branches:
# - main # Optional: Run automatically on pushes to the main branch # - 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: jobs:
deploy-to-sftp: deploy-to-sftp:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Was @v7, which does not exist (latest major is v5): the workflow
# failed on its very first step.
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Install lftp and ssh - name: Set up Python
run: sudo apt-get update && sudo apt-get install -y lftp openssh-client 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 - name: Set up SSH Private Key
env: env:
@@ -29,14 +102,51 @@ jobs:
- name: Push files via SFTP with progress - name: Push files via SFTP with progress
run: | run: |
# The mirror command below uses the -R (reverse) flag # --delete is deliberately NOT used. Uploaded contracts, logs and the
# to push from local './' to remote './' # server's own .env live under the deployment root and are absent
# Connection is made using 'open' inside the execution block to enforce SSH key usage # 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'; \ 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 sftp:auto-confirm yes; \
set net:max-retries 5; \ set net:max-retries 5; \
set net:timeout 30; \ set net:timeout 30; \
set cmd:fail-exit yes; \ set cmd:fail-exit yes; \
open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \ open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \
mirror -R --verbose --parallel=4 ./ ./; \ mirror -R --verbose --parallel=4 ./payload/ ./; \
quit" 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."
+54 -26
View File
@@ -2,7 +2,7 @@ name: CI - Security & Lint
on: on:
push: push:
branches: [main, master] branches: [main, master, 'audit/**']
pull_request: pull_request:
branches: [main, master] branches: [main, master]
workflow_dispatch: # Allow manual triggers workflow_dispatch: # Allow manual triggers
@@ -11,84 +11,112 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
# Least privilege: nothing here writes back to the repository.
permissions:
contents: read
# Third-party actions are pinned to a commit, with the version in a comment
# (CI-003). A tag is a moving pointer: whoever can move `v4` runs code in a
# job that holds this repository's token. The comment is what makes the pin
# maintainable — a bare 40-character hash tells a reader nothing about
# whether it is current. Dependabot updates both together.
env:
PYTHON_VERSION: '3.12'
jobs: jobs:
security-audit: security-audit:
name: Security Audit name: Security Audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.12' python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip' cache: 'pip'
- name: Install dependencies - name: Install pip-audit
run: pip install pip-audit run: pip install pip-audit==2.9.0
- name: Scan for vulnerable dependencies # Previously: `pip-audit --require-hashes --no-deps || pip-audit`.
run: pip-audit --require-hashes --no-deps || pip-audit # Neither form named the requirements file, so the fallback audited the
# runner's environment — which contained pip-audit and nothing else.
# The job passed green while checking none of the application's
# dependencies. -r makes it audit what the application actually pins.
- name: Scan declared dependencies for known vulnerabilities
run: pip-audit -r requirements.txt
lint: lint:
name: Lint with Ruff name: Lint with Ruff
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.12' python-version: ${{ env.PYTHON_VERSION }}
- name: Install ruff - name: Install ruff
run: pip install ruff run: pip install ruff==0.14.4
# Rule selection and per-file ignores live in pyproject.toml. Before it
# existed, this step ran ruff's bare defaults with no configuration.
- name: Run ruff linter - name: Run ruff linter
run: ruff check . --output-format=github run: ruff check . --output-format=github
- name: Run ruff formatter check # Enabled now that the repository has been formatted once, in its own
# commit (QUA-002). Reaching this step before that would have failed on
# 72 of 76 files for reasons unrelated to correctness.
- name: Check formatting
run: ruff format --check . run: ruff format --check .
security-scan: security-scan:
name: Security Scan name: Security Scan
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.12' python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip' cache: 'pip'
- name: Install app dependencies - name: Install app dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt -r requirements-dev.txt
# The path was `security_scan.py`, but the script lives under
# app/supporting_scripts/. The step had therefore failed on every run
# since the file was moved.
- name: Run security scan - name: Run security scan
env: env:
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }} SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
DATABASE_URL: 'sqlite:///:memory:'
FLASK_DEBUG: 'false' FLASK_DEBUG: 'false'
run: python security_scan.py --skip-http run: python app/supporting_scripts/security_scan.py --skip-http
test: test:
name: Tests name: Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [security-audit, lint] needs: [security-audit, lint]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.12' python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip' cache: 'pip'
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt -r requirements-dev.txt
# Previously an `echo` guarded by continue-on-error: the job reported
# success without executing anything. The suite needs no environment
# variables and no database server: create_app() takes its configuration
# as an argument and the fixtures use a temporary SQLite file.
- name: Run tests - name: Run tests
run: | run: pytest --cov=app --cov-report=term-missing --cov-report=xml
echo "No tests configured yet. Add tests to the project."
# python -m pytest tests/ --cov=. --cov-report=xml
continue-on-error: true
+145 -21
View File
@@ -1,27 +1,151 @@
.env # =============================================================================
# Secrets et configuration locale
# =============================================================================
# `.env*` couvre .env, .env.local, .env.production, .env.bak…
# L'exception laisse passer le seul fichier modèle, qui ne doit contenir
# que des valeurs factices.
.env*
!.env.example
.instance/ *.key
*.db
documents/
__pycache__/
*.cpython-313.pyc
*.cpython-312.pyc
*.pyc
.pytest_cache/
.coverage
htmlcov/
.DS_Store
*.log
.certs/
*.pem *.pem
*.pfx
*.p12
.certs/
id_rsa
id_ed25519
known_hosts
credentials.json
service-account*.json
docs/ # =============================================================================
*.html # 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 *.db
*.sqlite
*.sqlite3
# Contrats téléversés (données personnelles)
documents/
# Sauvegardes produites par app/supporting_scripts/backup.py
backups/
# Journaux applicatifs
logs/
*.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
+126 -63
View File
@@ -1,90 +1,153 @@
# Plateforme centralisée de tryouts # Plateforme centralisée de tryouts
## Security Configuration Application interne du club e-sport de l'UdeS : inscriptions aux sélections,
évaluations, gestion des équipes, disponibilités, contrats, et notifications
Discord.
### Required Environment Variables Le site est servi **en français**, l'anglais reste accessible par le sélecteur
de la barre latérale (voir `docs/translations.md`).
Before deploying, create a `.env` file which integrates everything in the .env.exemple. ---
Ensure you follow the comments of the exemple if you are to use this tool in production.
## Démarrer
### Security Features Implemented ```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
```
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks `SECRET_KEY` et `DATABASE_URL` sont **obligatoires** : `create_app()` refuse
- **Secure Session Cookies**: HTTPSOnly, SameSite=Lax, and Secure flags enabled de démarrer sans eux. `DATABASE_URL` doit pointer sur PostgreSQL ; le pilote
- **CSRF Protection**: Enabled by default on all forms psycopg 3 est nommé automatiquement si l'URL n'en nomme pas.
- **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
- **nginx**: reverse-proxy and load balancer
- **Waitress WSGI**: Production ready WSGI
### When true in .env: Production : `python wsgi.py` (Waitress derrière nginx). Voir
- **Forces HTTPS only** `docs/deployment.md`.
- **Forcer secure cookies**
## App details Ce sont les **deux seuls** points d'entrée.
### Code ## Vérifier
- Full python backend using flask ```bash
- statics are pure HTML and CSS .venv/Scripts/python -m pytest # suite complète
- Some js to add logic to styling and showing certain pages/cards .venv/Scripts/python -m ruff check . # lint
.venv/Scripts/python -m ruff format --check .
```
### Functionalities Les trois tournent en CI et y sont bloquants.
- **User base with sign-ins**: Forces users to create an account and register pertinent information for tryouts and teams. The admin can attribute them a role. ---
- **User-Role-Based Permissions**: admin - full acces, coach/manager - access to team management, player - views what he is registered in (no management), scout - view only
- **Tryout Management**: manage internal tryout teams, organise internal tryouts matches (3 formats, team vs team, PvP, scrim). Coaches can Evaluate players based on 10 criteria
- **Team Management**: manage teams for the season, create matches and practices. When planning a practice there will be a calendar showing player availabitlities slots to help chose a time
- **Coach and Player Availabilities**: Allow better planning for the coaches, and for players to book One on Ones with their coach.
- **Player Notes**: Coaches can give notes to their players. The players will see them and there is a history which keeps the most recent notes.
- **Team Notes**: Coaches can give notes to their teams, where all players from that team can see the note.
- **One on One**: Players can request a One on One meeting with their coach. This sends a discord dm to the coach to accept or refuse. The player is then notified of the response.
- **Availabilities**: Allow players and coach to enter the moments they are available. Allows for easier practice setup and One on One planning.
## Ce que fait l'application
## Discord Integration - **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.
The application supports sending Discord direct messages to coaches when players request One on One sessions, ## Comment c'est construit
when matches/tryouts/practices are created and a player is in it, and the players get match reminders 24h before a match.
When sending a **One on One** request, the coach can accept via the platform or react to the discord message to answer the booking request. Backend Python 3.12 / Flask, rendu serveur en Jinja2, CSS et JavaScript
Same thing with **matches** and **practices**, the players can react or answer on the platform. maison, sans framework front. Base PostgreSQL via SQLAlchemy. Bot Discord
(`discord.py`) dans un fil du même processus que le serveur web.
### Setup Instructions `docs/architecture.md` contient les diagrammes (classes, paquets, flux
d'une requête).
#### 1. Create a Discord Bot (Not needed for UdeS user, the bot already exists) ---
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) ## Sécurité
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. Add Bot to your server En place et vérifié par des tests :
For the bot to send DMs: - **Limitation de débit** sur la connexion (10 requêtes/minute par IP).
1. Each user must have the bot added to their Discord server OR be friends with the bot - **Cookies de session** `HttpOnly`, `SameSite=Lax`, `Secure`, avec
2. Users need to add their Discord User ID to their profile: expiration effective.
- Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode) - **CSRF** sur tous les formulaires, y compris la déconnexion (en POST).
- Right-click on their profile → Copy ID - **HTTPS** forcé en production, **HSTS**.
- Enter this numeric ID in the "Discord User ID" field in their profile settings - **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 :
### How It Works - **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`).
When a player submits a One on One request: `docs/security-checklist.md` détaille la liste avant mise en production.
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
### Message Format ### Documentation
The Discord DM includes: | Document | Pour |
- Player name |---|---|
- Team name | `docs/deployment.md` | Installer, déployer, revenir en arrière |
- Requested date and time slot | `docs/roles-and-permissions.md` | Qui peut faire quoi, et où c'est décidé |
- Discussion points (if provided) | `docs/database-restore.md` | Sauvegarder et restaurer |
- Link to the application for approval/rejection | `docs/database-schema.md` | Sortir de `create_all()` : relevé, Alembic, migrations |
- Two provided reactions to accept or refuse via discord | `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é.
+144
View File
@@ -0,0 +1,144 @@
# Team Tryouts — environment variables
#
# Copy to .env and fill in. Every value here is a PRODUCTION-SAFE default:
# copying this file and changing nothing gives a locked-down configuration
# that refuses to start until the two required secrets are set, rather than
# a working one that happens to be wide open (OPS-003).
#
# The previous version shipped FLASK_DEBUG=true under a heading that said
# "fill in the values for production". The Werkzeug debugger executes code
# submitted through the browser, so that one line turned a copy-paste into a
# remote shell.
#
# For local development, see the DEVELOPMENT block at the bottom.
# =============================================================================
# Required — the application refuses to start without these
# =============================================================================
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
# Never reuse one between environments: this key signs session cookies, so
# whoever holds it can forge a session for any account.
SECRET_KEY=
# Expected form: postgresql://user:password@host:5432/database
# The psycopg 3 driver is named for you by create_app(); postgresql:// alone
# would send SQLAlchemy looking for psycopg2, which is not installed.
DATABASE_URL=
# =============================================================================
# Security — these defaults assume HTTPS in front. Do not relax them on a
# deployed instance.
# =============================================================================
# Session cookies are only sent over HTTPS.
SESSION_COOKIE_SECURE=true
# Plain HTTP is redirected to HTTPS.
FORCE_HTTPS=true
# The Werkzeug debugger is a remote code execution primitive by design.
# Never true on anything reachable from a network you do not control.
FLASK_DEBUG=false
# Inline <script> without a nonce. Off: every block carries one, and turning
# this on gives up the protection that would have blocked the stored XSS
# (SEC-WEB-001). It exists as an escape hatch, not as a setting to tune.
CSP_ALLOW_INLINE_SCRIPT=false
# Comma-separated origins allowed to call this API cross-site. Empty means
# no CORS policy at all, which is correct: the site renders its own HTML on
# one origin and needs none.
CORS_ALLOWED_ORIGINS=
# =============================================================================
# Networking
# =============================================================================
# Interface Waitress binds. 127.0.0.1 keeps it reachable only through the
# local reverse proxy; 0.0.0.0 exposes it directly and is only correct if
# something else in front is doing the filtering.
HOST=127.0.0.1
PORT=5000
# Whether to believe X-Forwarded-For, and from whom. This decides which IP
# the rate limiter and the audit log record.
#
# (empty) — trust nobody. Correct when nothing proxies the app.
# 127.0.0.1 — trust a reverse proxy on this same machine. The usual case.
# * — trust everyone. Only ever correct if the app cannot be reached
# except through the proxy, at the network level. Otherwise any
# caller can claim any IP and walk around the rate limit.
#
# See docs/deployment.md before changing this (OPS-002).
TRUSTED_PROXY=127.0.0.1
# =============================================================================
# Optional — Discord
# =============================================================================
# Leave ENABLE_DISCORD_BOT=false and the token empty to run without Discord.
ENABLE_DISCORD_BOT=false
DISCORD_BOT_TOKEN=
# OAuth2, for "Connect Discord" on the sign-up page.
# Create an application at https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
# =============================================================================
# Optional — storage
# =============================================================================
# Where uploaded contracts live. Empty means `documents/` beside the
# application. Set it to a path OUTSIDE the deployment directory if you move
# to a release-directory layout, or a deployment will take the documents with
# it (OPS-011, app/storage.py).
#
# IMPORTANT: the backup script reads this same variable. Before wave J it
# did not, and archived `./documents` regardless — so setting this here and
# nowhere else produced empty contract backups that still exited 0.
DOCUMENTS_ROOT=
# Where the log files go. Empty means `logs/` beside the application. Both
# defaults are anchored on the application, not on the directory the process
# was started from, which is what they used to be (OBS-006).
LOG_DIR=
# Where the backup script writes its archives. Empty means `backups/` beside
# the application.
BACKUP_DIR=
# =============================================================================
# Optional — rate limiting
# =============================================================================
# Where the rate limiter keeps its counters. Empty means `memory://`, which
# is correct for a single Waitress process and is what this deployment runs.
#
# Set it to a shared backend (redis://…) BEFORE running more than one worker:
# in-memory counters are per-process, so N workers let through N times every
# configured limit, with nothing to show for it in the logs.
#
# Note that shared storage does not by itself make the limits sound: they are
# keyed on the client IP, which is forgeable until TRUSTED_PROXY is set
# correctly (SEC-WEB-002 / OPS-002 — see above).
RATELIMIT_STORAGE_URI=
# Tables are created at startup when missing. Set to false once Alembic owns
# the schema (DB-002/DB-004): create_all() never ALTERs, so a column added to
# a model is silently absent from an existing database.
AUTO_CREATE_TABLES=true
# =============================================================================
# DEVELOPMENT ONLY — the values to change on a laptop, and nowhere else
# =============================================================================
#
# FLASK_DEBUG=true reloader and interactive debugger
# SESSION_COOKIE_SECURE=false cookies over plain HTTP
# FORCE_HTTPS=false no redirect to HTTPS
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
#
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
# development session cannot accidentally inherit a production binding.
-32
View File
@@ -1,32 +0,0 @@
# Team Tryouts Application - Environment Variables
# Copy this file to .env and fill in the values for production
# Security Configuration
# Generate a secure random secret key: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=flask_app_secret_key
# Set to 'true' in production to enable secure cookies (requires HTTPS)
SESSION_COOKIE_SECURE=false
FORCE_HTTPS=false
# Flask Debug Mode - Set to 'true' only in development
FLASK_DEBUG=true
# Discord Bot Token (required for notifications)
# This is the UdeS Esports BOT token, it will send notifications to people that have their
# Dicord_User_ID in the db / remove if you don't want discord notifs.
DISCORD_BOT_TOKEN=my_discord_bot_token
# Discord OAuth2 Configuration (for "Connect Discord" on sign-up page)
# Create an application at https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
#where to find the db (hosted on render for now)
DATABASE_URL=URI_vers_db_posgres
#Where the app will be hosted (corresponds to: localhost:5000 in local)
HOST=127.0.0.1
PORT=5000
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+47
View File
@@ -0,0 +1,47 @@
"""Marking the views whose answers — including their failures — are JSON.
STD-09. Deciding "JSON or HTML page" from the URL path could not work here,
and the audit's own recommendation ("gestion d'erreurs API par préfixe d'URL
codé en dur", fix the prefixes) would not have fixed it either. Three of the
sixteen JSON views sit at paths no prefix can single out:
/matches/<int:match_id>/toggle-presence/<int:participant_id>
/team-matches/<int:match_id>/toggle-presence/<int:participant_id>
/teams/<int:team_id>/toggle_status/<int:player_id>
They are interleaved with the HTML routes of the same blueprints, and the
templates fetch them. Any prefix wide enough to catch them catches every
page of the section with them.
So the view says so itself. `wants_json_response()` in app.py reads the mark
off the registered view function, and `tests/test_api_error_format.py` walks
the URL map to prove that every view calling `jsonify` carries it — the
mechanism that was missing before was not a better list, it was anything at
all that checked the list.
Usage — directly under the route decorator, above `login_required`, so the
mark lands on the object the route registers::
@matches_bp.route('/api/events')
@json_endpoint
@login_required
def api_events():
...
"""
def json_endpoint(view):
"""Mark a view as answering in JSON, errors included.
Args:
view: The view function, already wrapped by any decorator below this
one (`login_required` in every current case).
Returns:
The same object, with the mark set. Nothing is wrapped: an extra
wrapper here would be one more thing between Flask and the view for
no gain, and `functools.wraps` copying `__dict__` is exactly the
detail that would make this fragile.
"""
view.returns_json = True
return view
+456 -109
View File
@@ -5,18 +5,64 @@ the Flask application instance with comprehensive security hardening.
""" """
import os import os
from flask import Flask, request, redirect, jsonify, render_template, url_for import secrets
from flask_cors import CORS
from app.extensions import db, login_manager, csrf, hash_password, check_password, limiter
from sqlalchemy import text
from werkzeug.exceptions import HTTPException
import markupsafe import markupsafe
from dotenv import load_dotenv from dotenv import load_dotenv
import os as _os 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
# Load .env from the app directory (independent of the process working directory)
_env_path = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), '.env')
load_dotenv(_env_path)
def nl2br(value): def nl2br(value):
"""Convert newlines to HTML line breaks. """Convert newlines to HTML line breaks.
@@ -28,13 +74,103 @@ def nl2br(value):
Markup: HTML-safe string with line breaks. Markup: HTML-safe string with line breaks.
""" """
if value: if value:
return markupsafe.Markup('<br>'.join(str(value).splitlines())) # 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 '' return ''
def create_app(): 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. """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: Initializes Flask with:
- Secret key for session security - Secret key for session security
- Database configuration - Database configuration
@@ -54,36 +190,103 @@ def create_app():
Flask: Configured Flask application instance. Flask: Configured Flask application instance.
""" """
app = Flask(__name__) 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['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']: if not app.config['SECRET_KEY']:
raise RuntimeError('SECRET_KEY environment variable must be set for security') raise RuntimeError('SECRET_KEY environment variable must be set for security')
database_url = os.getenv('DATABASE_URL') if not app.config['SQLALCHEMY_DATABASE_URI']:
if not database_url:
raise RuntimeError( raise RuntimeError(
'DATABASE_URL environment variable must be set to a PostgreSQL connection string' 'DATABASE_URL environment variable must be set to a PostgreSQL connection string'
) )
# Use psycopg2 driver for PostgreSQL
if database_url.startswith('postgresql://'): # After the overrides, so a caller-supplied URL is normalised too.
database_url = database_url.replace('postgresql://', 'postgresql+psycopg2://', 1) app.config['SQLALCHEMY_DATABASE_URI'] = normalise_database_url(
elif database_url.startswith('postgres://'): app.config['SQLALCHEMY_DATABASE_URI']
database_url = database_url.replace('postgres://', 'postgresql+psycopg2://', 1) )
app.config['SQLALCHEMY_DATABASE_URI'] = database_url
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['WTF_CSRF_ENABLED'] = True
# File upload size limit (16 MB) # File upload size limit (16 MB)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
# Secure session cookie settings # Secure session cookie settings
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true' app.config['SESSION_COOKIE_SECURE'] = (
os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
)
app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
# Configure CORS - restrict to specific origins in production # Configure CORS - restrict to specific origins in production
allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',') allowed_origins = str(app.config.get('CORS_ALLOWED_ORIGINS') or '').split(',')
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()] 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: if allowed_origins:
CORS( CORS(
app, app,
@@ -92,33 +295,122 @@ def create_app():
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
max_age=3600, # Cache preflight for 1 hour 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) db.init_app(app)
login_manager.init_app(app) login_manager.init_app(app)
csrf.init_app(app) csrf.init_app(app)
limiter.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 # Configure structured logging
from app.logging_config import configure_logging from app.logging_config import configure_logging
configure_logging(app) configure_logging(app)
from app.routes.auth import auth_bp from app.routes.auth import auth_bp
from app.routes.tryouts import tryouts_bp
from app.routes.evaluations import evaluations_bp from app.routes.evaluations import evaluations_bp
from app.routes.users import users_bp
from app.routes.main import main_bp from app.routes.main import main_bp
from app.routes.teams import teams_bp
from app.routes.matches import matches_bp from app.routes.matches import matches_bp
from app.routes.team_matches import team_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
from app.routes.admin import admin_bp from app.routes.admin import admin_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
@@ -147,25 +439,20 @@ def create_app():
HSTS is only sent in production (non-debug) to avoid breaking HSTS is only sent in production (non-debug) to avoid breaking
local development over plain HTTP. 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-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY' 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['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Permissions-Policy'] = ( response.headers['Permissions-Policy'] = (
'camera=(), microphone=(), geolocation=(), ' 'camera=(), microphone=(), geolocation=(), interest-cohort=(), payment=(), usb=()'
'interest-cohort=(), payment=(), usb=()'
) )
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin' response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
response.headers['Content-Security-Policy'] = ( response.headers['Content-Security-Policy'] = build_csp(
"default-src 'self'; " allow_inline_script=app.config['CSP_ALLOW_INLINE_SCRIPT'],
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " nonce=g.get('csp_nonce'),
"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'"
) )
# Only enable HSTS when HTTPS is actually being used # Only enable HSTS when HTTPS is actually being used
@@ -187,11 +474,17 @@ def create_app():
Respects the X-Forwarded-Proto header from reverse proxies. Respects the X-Forwarded-Proto header from reverse proxies.
Can be disabled via FORCE_HTTPS environment variable. Can be disabled via FORCE_HTTPS environment variable.
Returns:
Response | None: A redirect, or None to let the request through.
""" """
if not app.debug: if not app.debug and app.config['FORCE_HTTPS']:
if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https': already_secure = (
if os.getenv('FORCE_HTTPS', 'true').lower() == 'true': request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
return redirect(request.url.replace('http://', 'https://'), code=301) )
if not already_secure:
return redirect(request.url.replace('http://', 'https://'), code=301)
return None
# ========================================================================= # =========================================================================
# Health Check Endpoint # Health Check Endpoint
@@ -212,13 +505,28 @@ def create_app():
'version': '1.0.0', '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 # Check database connectivity
try: try:
db.session.execute(text('SELECT 1')) db.session.execute(text('SELECT 1'))
health_data['database'] = 'connected' health_data['database'] = 'connected'
except Exception as e: except Exception:
# Never echo the driver error: it routinely carries the host,
# database name and user of the connection string, and /health
# is unauthenticated.
app.logger.error('Health check: database unreachable', exc_info=True)
health_data['status'] = 'unhealthy' health_data['status'] = 'unhealthy'
health_data['database'] = f'error: {str(e)}' health_data['database'] = 'error'
return jsonify(health_data), 503 return jsonify(health_data), 503
return jsonify(health_data), 200 return jsonify(health_data), 200
@@ -226,6 +534,36 @@ def create_app():
# ========================================================================= # =========================================================================
# Custom Error Handlers # 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) @app.errorhandler(400)
def bad_request(error): def bad_request(error):
"""Handle 400 Bad Request errors. """Handle 400 Bad Request errors.
@@ -236,28 +574,21 @@ def create_app():
Returns: Returns:
Response: Rendered error page or JSON for API requests. Response: Rendered error page or JSON for API requests.
""" """
if request.path.startswith('/users/disponibilities') or \ if wants_json_response():
request.path.startswith('/users/coach-availability') or \
request.path.startswith('/users/api/'):
return jsonify({'error': 'Bad request', 'message': str(error)}), 400 return jsonify({'error': 'Bad request', 'message': str(error)}), 400
return render_template('errors/400.html', error=error), 400 return render_template('errors/400.html', error=error), 400
@app.errorhandler(401) @app.errorhandler(401)
def unauthorized(error): def unauthorized(error):
"""Handle 401 Unauthorized errors. """Handle an explicit abort(401).
Args: Rarely reached: `@login_required` is intercepted by Flask-Login
error: The error object. before Flask's error handling, and answered by handle_unauthorized
above. This covers code that aborts with 401 itself, and gives the
Returns: same answer — the two used to differ, and the flash message here was
Response: Redirect to login for pages, JSON for API. the one string in the application that had never been translated.
""" """
if request.path.startswith('/users/disponibilities') or \ return handle_unauthorized()
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) @app.errorhandler(403)
def forbidden(error): def forbidden(error):
@@ -269,8 +600,7 @@ def create_app():
Returns: Returns:
Response: Rendered error page or JSON for API requests. Response: Rendered error page or JSON for API requests.
""" """
if request.path.startswith('/users/disponibilities') or \ if wants_json_response():
request.path.startswith('/users/api/'):
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403 return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
return render_template('errors/403.html', error=error), 403 return render_template('errors/403.html', error=error), 403
@@ -284,8 +614,7 @@ def create_app():
Returns: Returns:
Response: Rendered error page or JSON for API requests. Response: Rendered error page or JSON for API requests.
""" """
if request.path.startswith('/users/disponibilities') or \ if wants_json_response():
request.path.startswith('/users/api/'):
return jsonify({'error': 'Not found'}), 404 return jsonify({'error': 'Not found'}), 404
return render_template('errors/404.html', error=error), 404 return render_template('errors/404.html', error=error), 404
@@ -299,12 +628,10 @@ def create_app():
Returns: Returns:
Response: JSON error for API or rendered page. Response: JSON error for API or rendered page.
""" """
if request.path.startswith('/users/disponibilities') or \ if wants_json_response():
request.path.startswith('/users/api/'): return jsonify(
return jsonify({ {'error': 'Too many requests', 'message': 'Please try again later.'}
'error': 'Too many requests', ), 429
'message': 'Please try again later.'
}), 429
return render_template('errors/429.html', error=error), 429 return render_template('errors/429.html', error=error), 429
@app.errorhandler(500) @app.errorhandler(500)
@@ -325,13 +652,21 @@ def create_app():
# Roll back any failed database session # Roll back any failed database session
db.session.rollback() db.session.rollback()
if request.path.startswith('/users/disponibilities') or \ # The id is the only thing that connects a user saying "it broke when
request.path.startswith('/users/api/'): # I clicked save" to the stack trace in errors.log. It identifies one
return jsonify({ # request and nothing else — no session, no account, nothing an
'error': 'Internal server error', # attacker can use — so showing it costs nothing (OBS-005).
'message': 'An unexpected error occurred. Please try again later.' request_id = g.get('request_id', '-')
}), 500
return render_template('errors/500.html'), 500 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) @app.errorhandler(HTTPException)
def handle_http_exception(error): def handle_http_exception(error):
@@ -343,13 +678,10 @@ def create_app():
Returns: Returns:
Response: JSON error for API, re-raises for others. Response: JSON error for API, re-raises for others.
""" """
if request.path.startswith('/users/disponibilities') or \ if wants_json_response():
request.path.startswith('/users/api/'): return jsonify(
return jsonify({ {'error': error.name, 'message': error.description, 'code': error.code}
'error': error.name, ), error.code
'message': error.description,
'code': error.code
}), error.code
return error return error
# ========================================================================= # =========================================================================
@@ -357,25 +689,40 @@ def create_app():
# ========================================================================= # =========================================================================
with app.app_context(): with app.app_context():
import app.models as models # noqa: F401 — registers all models with SQLAlchemy import app.models as models # noqa: F401 — registers all models with SQLAlchemy
db.create_all()
# NOTE: create_all() only ever creates missing tables. It never adds a
# column to an existing one, so a model change is silently absent from
# any database that already has the table. Replacing this with Alembic
# is tracked as DB-002/DB-004; until then the behaviour is preserved.
if app.config['AUTO_CREATE_TABLES']:
db.create_all()
# Start the Discord bot for notifications # Start the Discord bot for notifications
try: if app.config['ENABLE_DISCORD_BOT']:
from app.discord_bot import start_bot try:
start_bot(flask_app=app) from app.discord_bot import start_bot
except Exception as e:
app.logger.warning('Could not start Discord bot: %s', e) 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 return app
if __name__ == '__main__': # No __main__ block here on purpose. There used to be one, and with run.py
# Only used for development - production uses wsgi.py (Waitress) # and wsgi.py that made three ways to start the application, each with its
app = create_app() # own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true' # while `python run.py` bound 127.0.0.2:5000 with the debugger on. This
if debug_mode: # module defines the factory; run.py starts it for development, wsgi.py for
app.logger.warning( # production (ARCH-007).
'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='0.0.0.0', port=10000)
+1274 -500
View File
File diff suppressed because it is too large Load Diff
+15 -9
View File
@@ -1,9 +1,10 @@
from flask_sqlalchemy import SQLAlchemy from flask_babel import Babel
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from werkzeug.security import generate_password_hash, check_password_hash
from flask_limiter import Limiter from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect
from werkzeug.security import check_password_hash, generate_password_hash
# Database and extension initialization # Database and extension initialization
db = SQLAlchemy() db = SQLAlchemy()
@@ -12,11 +13,16 @@ login_manager.login_view = 'auth.login'
login_manager.login_message_category = 'info' login_manager.login_message_category = 'info'
csrf = CSRFProtect() csrf = CSRFProtect()
# Rate limiter for brute-force protection # Internationalisation. French is the primary language of the site; English
limiter = Limiter( # stays available. See app/i18n.py for how a locale is chosen.
key_func=get_remote_address, babel = Babel()
default_limits=["200 per day", "50 per hour"]
) # Rate limiter for brute-force protection.
#
# No storage is named here on purpose: it comes from RATELIMIT_STORAGE_URI in
# app.config, which create_app fills from the environment and defaults to
# `memory://` (SEC-WEB-004). Naming it in both places is how the two drift.
limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "50 per hour"])
def hash_password(password): def hash_password(password):
+93
View File
@@ -0,0 +1,93 @@
"""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
@@ -0,0 +1,75 @@
"""Language selection.
French is the primary language of the site; English remains available.
Source strings stay in English and act as gettext message ids, with the
French wording supplied by translations/fr/LC_MESSAGES/messages.po. That
keeps the codebase in one language — the same one as its comments and
docstrings — while what a member actually sees defaults to French.
Consequence worth knowing: an English page is what you get when a string
has no French translation yet. A missing entry degrades to English rather
than to a raw identifier, which is why the migration can proceed template
by template without ever leaving the site in a broken state.
"""
from flask import request, session
#: Locales the site is served in, in order of preference.
SUPPORTED_LOCALES = ('fr', 'en')
#: Language names as written in their own language, for the switcher.
LOCALE_NAMES = {
'fr': 'Français',
'en': 'English',
}
#: Session key holding an explicit user choice.
LOCALE_SESSION_KEY = 'locale'
DEFAULT_LOCALE = 'fr'
def select_locale():
"""Pick the locale for the current request.
Order of precedence:
1. an explicit choice the user made through the language switcher,
kept in the session;
2. the browser's Accept-Language header, restricted to what we serve;
3. French.
Note that step 2 only ever selects English for someone whose browser
actually asks for it. Everyone else gets French, including browsers
sending no header at all.
Returns:
str: A locale code from SUPPORTED_LOCALES.
"""
chosen = session.get(LOCALE_SESSION_KEY)
if chosen in SUPPORTED_LOCALES:
return chosen
# best_match returns None when nothing overlaps.
if request:
negotiated = request.accept_languages.best_match(SUPPORTED_LOCALES)
if negotiated:
return negotiated
return DEFAULT_LOCALE
def set_locale(locale):
"""Record an explicit language choice for this session.
Args:
locale: Requested locale code.
Returns:
bool: True if it was accepted, False if unsupported.
"""
if locale not in SUPPORTED_LOCALES:
return False
session[LOCALE_SESSION_KEY] = locale
return True
+145 -26
View File
@@ -11,8 +11,41 @@ Usage:
import logging import logging
import os import os
from logging.handlers import RotatingFileHandler
import re 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): class SensitiveDataFilter(logging.Filter):
@@ -24,14 +57,30 @@ class SensitiveDataFilter(logging.Filter):
# Patterns to redact # Patterns to redact
SENSITIVE_PATTERNS = [ 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'(?: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'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'), (re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
] ]
def filter(self, record): def filter(self, record):
"""Apply redaction to the log record's message. """Apply redaction to the log record's fully rendered message.
The record is rendered first (msg % args) and the result stored back
as msg with args cleared. Redacting record.msg alone would miss almost
everything: this codebase logs with %s placeholders, so the sensitive
value lives in record.args while record.msg holds only the format
string.
Args: Args:
record: The log record to filter. record: The log record to filter.
@@ -39,14 +88,21 @@ class SensitiveDataFilter(logging.Filter):
Returns: Returns:
bool: Always True (never drops records, only redacts). bool: Always True (never drops records, only redacts).
""" """
if hasattr(record, 'msg') and isinstance(record.msg, str): try:
msg = record.msg rendered = record.getMessage()
for pattern, replacement in self.SENSITIVE_PATTERNS: except Exception: # noqa: BLE001 — see below; this one cannot log its own failure
if callable(replacement): # A malformed format string must not lose the record entirely.
msg = pattern.sub(replacement, msg) # Nor can it be logged: this runs inside a filter, and logging
else: # from here re-enters the same filter on the new record. The
msg = pattern.sub(replacement, msg) # traceback BLE001 normally asks for is the one thing this
record.msg = msg # 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 return True
@@ -63,7 +119,12 @@ def configure_logging(app):
Args: Args:
app: The Flask application instance to configure logging for. app: The Flask application instance to configure logging for.
""" """
log_dir = os.path.join(os.getcwd(), 'logs') # 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) os.makedirs(log_dir, exist_ok=True)
# Remove default Flask handlers to avoid duplicate logging # Remove default Flask handlers to avoid duplicate logging
@@ -76,11 +137,17 @@ def configure_logging(app):
# Create the sensitive data filter # Create the sensitive data filter
sensitive_filter = SensitiveDataFilter() sensitive_filter = SensitiveDataFilter()
request_id_filter = RequestIdFilter()
# Formatter with timestamp, level, module, and message # 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( formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s', '[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] [%(request_id)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S' datefmt='%Y-%m-%d %H:%M:%S',
) )
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -89,11 +156,12 @@ def configure_logging(app):
error_handler = RotatingFileHandler( error_handler = RotatingFileHandler(
os.path.join(log_dir, 'errors.log'), os.path.join(log_dir, 'errors.log'),
maxBytes=10 * 1024 * 1024, # 10 MB maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10 backupCount=10,
) )
error_handler.setLevel(logging.ERROR) error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter) error_handler.setFormatter(formatter)
error_handler.addFilter(sensitive_filter) error_handler.addFilter(sensitive_filter)
error_handler.addFilter(request_id_filter)
app.logger.addHandler(error_handler) app.logger.addHandler(error_handler)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -102,11 +170,12 @@ def configure_logging(app):
auth_handler = RotatingFileHandler( auth_handler = RotatingFileHandler(
os.path.join(log_dir, 'auth.log'), os.path.join(log_dir, 'auth.log'),
maxBytes=10 * 1024 * 1024, # 10 MB maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5 backupCount=5,
) )
auth_handler.setLevel(logging.INFO) auth_handler.setLevel(logging.INFO)
auth_handler.setFormatter(formatter) auth_handler.setFormatter(formatter)
auth_handler.addFilter(sensitive_filter) auth_handler.addFilter(sensitive_filter)
auth_handler.addFilter(request_id_filter)
# Create a named logger specifically for auth events # Create a named logger specifically for auth events
auth_logger = logging.getLogger('team_tryouts.auth') auth_logger = logging.getLogger('team_tryouts.auth')
@@ -120,22 +189,40 @@ def configure_logging(app):
app_handler = RotatingFileHandler( app_handler = RotatingFileHandler(
os.path.join(log_dir, 'app.log'), os.path.join(log_dir, 'app.log'),
maxBytes=10 * 1024 * 1024, # 10 MB maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10 backupCount=10,
) )
app_handler.setLevel(log_level) app_handler.setLevel(log_level)
app_handler.setFormatter(formatter) app_handler.setFormatter(formatter)
app_handler.addFilter(sensitive_filter) app_handler.addFilter(sensitive_filter)
app_handler.addFilter(request_id_filter)
app.logger.addHandler(app_handler) app.logger.addHandler(app_handler)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# 4. Console Handler (for development) # 4. Console Handler (always on)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
if os.getenv('FLASK_DEBUG', 'false').lower() == 'true': # Previously gated on FLASK_DEBUG, which meant production emitted nothing
console_handler = logging.StreamHandler() # on stdout — precisely where the Pterodactyl console looks. Keep the
console_handler.setLevel(logging.DEBUG) # handler unconditional and vary the level instead.
console_handler.setFormatter(formatter) debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
console_handler.addFilter(sensitive_filter) console_handler = logging.StreamHandler()
app.logger.addHandler(console_handler) 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 # Log startup information
app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir) app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir)
@@ -152,3 +239,35 @@ def get_auth_logger():
logging.Logger: Logger for authentication events. logging.Logger: Logger for authentication events.
""" """
return logging.getLogger('team_tryouts.auth') 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))
+1
View File
@@ -20,6 +20,7 @@ from app.models._constants import (
GAME_PLATFORMS, GAME_PLATFORMS,
PLATFORM_CODES, PLATFORM_CODES,
TRN_URLS, TRN_URLS,
EVALUATION_CRITERIA,
) )
# ========================================================================= # =========================================================================
+40 -16
View File
@@ -2,24 +2,48 @@
from app.extensions import db from app.extensions import db
org_team_coaches = db.Table(
org_team_coaches = db.Table('org_team_coaches', 'org_team_coaches',
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'), db.Column(
primary_key=True), 'org_team_id',
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), db.Integer,
primary_key=True), 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', org_team_managers = db.Table(
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'), 'org_team_managers',
primary_key=True), db.Column(
db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), 'org_team_id',
primary_key=True), 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', tryout_coaches = db.Table(
db.Column('tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), 'tryout_coaches',
primary_key=True), db.Column(
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), 'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
primary_key=True), ),
db.Column(
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
tryout_managers = db.Table(
'tryout_managers',
db.Column(
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
),
db.Column(
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
) )
+16 -5
View File
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout'] 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 = [ ESPORT_GAMES = [
'Valorant', 'Valorant',
'League of Legends', 'League of Legends',
@@ -52,11 +67,7 @@ PLATFORM_CODES = {
'Epic': 'epic', 'Epic': 'epic',
} }
PLATFORM_DEFAULTS = { PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
'Apex Legends': 'pc',
'Rainbow Six Siege': 'ubi',
'Rocket League': 'epic'
}
TRN_URLS = { TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}', 'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
+16 -1
View File
@@ -9,6 +9,21 @@ def load_user(user_id):
Returns the correct polymorphic subclass (Admin, Coach, Player, etc.) Returns the correct polymorphic subclass (Admin, Coach, Player, etc.)
automatically because SQLAlchemy resolves the identity column. 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 from app.models.user_model.user import User
return User.query.get(int(user_id))
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
+1 -1
View File
@@ -1,7 +1,7 @@
"""Availability models — BaseAvailability and its concrete subclasses.""" """Availability models — BaseAvailability and its concrete subclasses."""
from app.models.availability.base import BaseAvailability from app.models.availability.base import BaseAvailability
from app.models.availability.player_disponibility import PlayerDisponibility
from app.models.availability.coach_availability import CoachAvailability from app.models.availability.coach_availability import CoachAvailability
from app.models.availability.player_disponibility import PlayerDisponibility
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability'] __all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
+5 -3
View File
@@ -1,14 +1,16 @@
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability).""" """Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class BaseAvailability(db.Model): class BaseAvailability(db.Model):
"""Shared schema for player disponibilities and coach availabilities.""" """Shared schema for player disponibilities and coach availabilities."""
__abstract__ = True __abstract__ = True
day_of_week = db.Column(db.Integer, nullable=False) day_of_week = db.Column(db.Integer, nullable=False)
start_time = db.Column(db.Time, nullable=False) start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
@@ -1,10 +1,12 @@
"""Coach availability in 30-minute time blocks for One on One sessions.""" """Coach availability in 30-minute time blocks for One on One sessions."""
from app.extensions import db from app.extensions import db
from app.models.availability.base import BaseAvailability from app.models.availability.base import BaseAvailability
class CoachAvailability(BaseAvailability): class CoachAvailability(BaseAvailability):
"""Coach availability in 30-minute blocks for One on One sessions.""" """Coach availability in 30-minute blocks for One on One sessions."""
__tablename__ = 'coach_availabilities' __tablename__ = 'coach_availabilities'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -1,10 +1,12 @@
"""Player availability in 30-minute time blocks.""" """Player availability in 30-minute time blocks."""
from app.extensions import db from app.extensions import db
from app.models.availability.base import BaseAvailability from app.models.availability.base import BaseAvailability
class PlayerDisponibility(BaseAvailability): class PlayerDisponibility(BaseAvailability):
"""Player availability in 30-minute blocks.""" """Player availability in 30-minute blocks."""
__tablename__ = 'player_disponibilities' __tablename__ = 'player_disponibilities'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+24 -8
View File
@@ -1,10 +1,12 @@
"""Contract documents for players to sign.""" """Contract documents for players to sign."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class Contract(db.Model): class Contract(db.Model):
"""Contract documents for players to sign.""" """Contract documents for players to sign."""
__tablename__ = 'contracts' __tablename__ = 'contracts'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -20,7 +22,7 @@ class Contract(db.Model):
status = db.Column(db.String(20), default='pending') status = db.Column(db.String(20), default='pending')
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) uploaded_at = db.Column(db.DateTime, default=utc_now_naive)
signed_at = db.Column(db.DateTime, nullable=True) signed_at = db.Column(db.DateTime, nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='contracts') player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
@@ -28,23 +30,37 @@ class Contract(db.Model):
uploader = db.relationship('User', foreign_keys=[uploaded_by_id]) uploader = db.relationship('User', foreign_keys=[uploaded_by_id])
def can_view(self, user): 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: if user.id == self.player_id:
return True return True
from app.models.user_model.admin import Admin 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.coach import Coach
from app.models.user_model.manager import Manager
from app.models.user_model.user import User from app.models.user_model.user import User
from app.models.org_team.org_team import OrgTeam from app.permissions import coach_can_access_player
if isinstance(user, Admin): if isinstance(user, Admin):
return True return True
if isinstance(user, Manager): if isinstance(user, Manager):
player = User.query.get(self.player_id) player = db.session.get(User, self.player_id)
if player and player.get_org_teams(): if player and player.get_org_teams():
return True return True
if isinstance(user, Coach): if isinstance(user, Coach):
org_team = OrgTeam.query.filter_by(coach_id=user.id).first() return coach_can_access_player(user, self.player_id)
if org_team and (not self.team_id or self.team_id == org_team.id):
return True
return False return False
def can_upload_signed(self, user): def can_upload_signed(self, user):
+51 -3
View File
@@ -1,10 +1,12 @@
"""Player evaluation record.""" """Player evaluation record."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class Evaluation(db.Model): class Evaluation(db.Model):
"""Player evaluation record.""" """Player evaluation record."""
__tablename__ = 'evaluations' __tablename__ = 'evaluations'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
@@ -22,9 +24,55 @@ class Evaluation(db.Model):
overall_score = db.Column(db.Float, nullable=True) overall_score = db.Column(db.Float, nullable=True)
comments = db.Column(db.Text, nullable=True) comments = db.Column(db.Text, nullable=True)
position_recommendation = db.Column(db.String(50), nullable=True) position_recommendation = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
__table_args__ = ( __table_args__ = (
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'), 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)
+1
View File
@@ -1,4 +1,5 @@
"""Match models — BaseMatch and its concrete subclasses.""" """Match models — BaseMatch and its concrete subclasses."""
from app.models.match_model.base import BaseMatch from app.models.match_model.base import BaseMatch
from app.models.match_model.match import Match from app.models.match_model.match import Match
from app.models.match_model.team_match import TeamMatch from app.models.match_model.team_match import TeamMatch
+4 -2
View File
@@ -1,10 +1,12 @@
"""Abstract base class for match models (Match + TeamMatch).""" """Abstract base class for match models (Match + TeamMatch)."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class BaseMatch(db.Model): class BaseMatch(db.Model):
"""Shared schema for tryout-scoped matches and regular-season team matches.""" """Shared schema for tryout-scoped matches and regular-season team matches."""
__abstract__ = True __abstract__ = True
title = db.Column(db.String(200), nullable=False) title = db.Column(db.String(200), nullable=False)
@@ -15,4 +17,4 @@ class BaseMatch(db.Model):
location = db.Column(db.String(200), nullable=True) location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='scheduled') status = db.Column(db.String(20), default='scheduled')
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
+9 -1
View File
@@ -1,10 +1,12 @@
"""Match / scrimmage within a tryout.""" """Match / scrimmage within a tryout."""
from app.extensions import db from app.extensions import db
from app.models.match_model.base import BaseMatch from app.models.match_model.base import BaseMatch
class Match(BaseMatch): class Match(BaseMatch):
"""Match / scrimmage within a tryout.""" """Match / scrimmage within a tryout."""
__tablename__ = 'matches' __tablename__ = 'matches'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
@@ -16,7 +18,13 @@ class Match(BaseMatch):
tryout = db.relationship('Tryout', backref='matches') tryout = db.relationship('Tryout', backref='matches')
team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1') team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1')
team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2') team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2')
participants = db.relationship('MatchParticipant', backref='match', lazy='dynamic') # 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): def get_participating_players(self):
return [p.player_id for p in self.participants.all()] return [p.player_id for p in self.participants.all()]
+4 -2
View File
@@ -1,10 +1,12 @@
"""Regular-season match for an organisation team (not tied to a tryout).""" """Regular-season match for an organisation team (not tied to a tryout)."""
from app.extensions import db from app.extensions import db
from app.models.match_model.base import BaseMatch from app.models.match_model.base import BaseMatch
class TeamMatch(BaseMatch): class TeamMatch(BaseMatch):
"""Regular-season match for an organisation team (not tied to a tryout).""" """Regular-season match for an organisation team (not tied to a tryout)."""
__tablename__ = 'team_matches' __tablename__ = 'team_matches'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
@@ -13,8 +15,8 @@ class TeamMatch(BaseMatch):
org_team = db.relationship('OrgTeam', backref='team_matches') org_team = db.relationship('OrgTeam', backref='team_matches')
creator = db.relationship('User', backref='created_team_matches') creator = db.relationship('User', backref='created_team_matches')
participants = db.relationship( participants = db.relationship(
'TeamMatchParticipant', backref='team_match', lazy='dynamic', 'TeamMatchParticipant', backref='team_match', lazy='dynamic', cascade='all, delete-orphan'
cascade='all, delete-orphan') )
def get_confirmed_count(self): def get_confirmed_count(self):
all_p = self.participants.all() all_p = self.participants.all()
+4 -2
View File
@@ -1,10 +1,12 @@
"""Request from player to coach for a One on One session.""" """Request from player to coach for a One on One session."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class OneOnOneRequest(db.Model): class OneOnOneRequest(db.Model):
"""Request from player to coach for a One on One session.""" """Request from player to coach for a One on One session."""
__tablename__ = 'one_on_one_requests' __tablename__ = 'one_on_one_requests'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -15,7 +17,7 @@ class OneOnOneRequest(db.Model):
end_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False)
points = db.Column(db.Text, nullable=True) points = db.Column(db.Text, nullable=True)
status = db.Column(db.String(20), default='pending') status = db.Column(db.String(20), default='pending')
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
responded_at = db.Column(db.DateTime, nullable=True) responded_at = db.Column(db.DateTime, nullable=True)
discord_message_id = db.Column(db.BigInteger, nullable=True) discord_message_id = db.Column(db.BigInteger, nullable=True)
coach_rejection_message = db.Column(db.Text, nullable=True) coach_rejection_message = db.Column(db.Text, nullable=True)
+1
View File
@@ -1,4 +1,5 @@
"""Organisation team models.""" """Organisation team models."""
from app.models.org_team.org_team import OrgTeam from app.models.org_team.org_team import OrgTeam
from app.models.org_team.team_player import TeamPlayer from app.models.org_team.team_player import TeamPlayer
+26 -12
View File
@@ -1,36 +1,48 @@
"""Persistent organisation team (e.g. Varsity, JV).""" """Persistent organisation team (e.g. Varsity, JV)."""
from app.extensions import db from app.extensions import db
from app.models._associations import org_team_coaches, org_team_managers from app.models._associations import org_team_coaches, org_team_managers
from datetime import datetime from app.time_utils import utc_now_naive
class OrgTeam(db.Model): class OrgTeam(db.Model):
"""Persistent organisation team (e.g. Varsity, JV).""" """Persistent organisation team (e.g. Varsity, JV)."""
__tablename__ = 'org_teams' __tablename__ = 'org_teams'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True) name = db.Column(db.String(100), nullable=False, unique=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
creator = db.relationship('User', foreign_keys=[created_by]) creator = db.relationship('User', foreign_keys=[created_by])
coaches = db.relationship( coaches = db.relationship(
'User', secondary=org_team_coaches, lazy='dynamic', 'User',
backref=db.backref('coached_org_teams', lazy='dynamic')) secondary=org_team_coaches,
lazy='dynamic',
backref=db.backref('coached_org_teams', lazy='dynamic'),
)
managers = db.relationship( managers = db.relationship(
'User', secondary=org_team_managers, lazy='dynamic', 'User',
backref=db.backref('managed_org_teams', lazy='dynamic')) secondary=org_team_managers,
lazy='dynamic',
backref=db.backref('managed_org_teams', lazy='dynamic'),
)
coach = db.relationship( coach = db.relationship(
'User', foreign_keys=[coach_id], 'User',
foreign_keys=[coach_id],
backref=db.backref('coached_org_team_legacy', uselist=False), backref=db.backref('coached_org_team_legacy', uselist=False),
viewonly=True) viewonly=True,
)
manager = db.relationship( manager = db.relationship(
'User', foreign_keys=[manager_id], 'User',
foreign_keys=[manager_id],
backref=db.backref('managed_org_team_legacy', uselist=False), backref=db.backref('managed_org_team_legacy', uselist=False),
viewonly=True) viewonly=True,
)
def get_coaches(self): def get_coaches(self):
coach_list = self.coaches.all() coach_list = self.coaches.all()
@@ -49,5 +61,7 @@ class OrgTeam(db.Model):
return [tp.player for tp in self.team_players] return [tp.player for tp in self.team_players]
def get_players_with_status(self): def get_players_with_status(self):
return [{'player': tp.player, 'status': tp.status, return [
'position': tp.position} for tp in self.team_players] {'player': tp.player, 'status': tp.status, 'position': tp.position}
for tp in self.team_players
]
+4 -2
View File
@@ -1,17 +1,19 @@
"""Many-to-many junction: player to org-team.""" """Many-to-many junction: player to org-team."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class TeamPlayer(db.Model): class TeamPlayer(db.Model):
"""Many-to-many: player to org-team.""" """Many-to-many: player to org-team."""
__tablename__ = 'team_players' __tablename__ = 'team_players'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) 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) org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
status = db.Column(db.String(20), nullable=False, default='starter') status = db.Column(db.String(20), nullable=False, default='starter')
position = db.Column(db.String(50), nullable=True) position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow) added_at = db.Column(db.DateTime, default=utc_now_naive)
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements') player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players') org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
+1
View File
@@ -1,4 +1,5 @@
"""Participant models — BaseParticipant and its concrete subclasses.""" """Participant models — BaseParticipant and its concrete subclasses."""
from app.models.participant.base import BaseParticipant from app.models.participant.base import BaseParticipant
from app.models.participant.match_participant import MatchParticipant from app.models.participant.match_participant import MatchParticipant
from app.models.participant.team_match_participant import TeamMatchParticipant from app.models.participant.team_match_participant import TeamMatchParticipant
+4 -2
View File
@@ -1,11 +1,13 @@
"""Abstract base class for match participant models.""" """Abstract base class for match participant models."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class BaseParticipant(db.Model): class BaseParticipant(db.Model):
"""Shared schema for match participants.""" """Shared schema for match participants."""
__abstract__ = True __abstract__ = True
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow) added_at = db.Column(db.DateTime, default=utc_now_naive)
@@ -1,10 +1,12 @@
"""Participant in a tryout-scoped match.""" """Participant in a tryout-scoped match."""
from app.extensions import db from app.extensions import db
from app.models.participant.base import BaseParticipant from app.models.participant.base import BaseParticipant
class MatchParticipant(BaseParticipant): class MatchParticipant(BaseParticipant):
"""Participant in a tryout-scoped match.""" """Participant in a tryout-scoped match."""
__tablename__ = 'match_participants' __tablename__ = 'match_participants'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False) match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
@@ -1,10 +1,12 @@
"""Participant in a regular-season team match.""" """Participant in a regular-season team match."""
from app.extensions import db from app.extensions import db
from app.models.participant.base import BaseParticipant from app.models.participant.base import BaseParticipant
class TeamMatchParticipant(BaseParticipant): class TeamMatchParticipant(BaseParticipant):
"""Participant in a regular-season team match.""" """Participant in a regular-season team match."""
__tablename__ = 'team_match_participants' __tablename__ = 'team_match_participants'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False) team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
+5 -3
View File
@@ -1,17 +1,19 @@
"""Personal notes from coach to individual player.""" """Personal notes from coach to individual player."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class PersonalNote(db.Model): class PersonalNote(db.Model):
"""Personal notes from coach to individual player.""" """Personal notes from coach to individual player."""
__tablename__ = 'personal_notes' __tablename__ = 'personal_notes'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach_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) content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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) match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
+1
View File
@@ -1,4 +1,5 @@
"""Tryout-specific temporary team models.""" """Tryout-specific temporary team models."""
from app.models.team.team import Team from app.models.team.team import Team
from app.models.team.team_member import TeamMember from app.models.team.team_member import TeamMember
+4 -2
View File
@@ -1,16 +1,18 @@
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" """Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class Team(db.Model): class Team(db.Model):
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" """Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
__tablename__ = 'teams' __tablename__ = 'teams'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
name = db.Column(db.String(100), nullable=False) name = db.Column(db.String(100), nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
creator = db.relationship('User', backref='created_teams') creator = db.relationship('User', backref='created_teams')
members = db.relationship('TeamMember', backref='team', lazy='dynamic') members = db.relationship('TeamMember', backref='team', lazy='dynamic')
+4 -2
View File
@@ -1,15 +1,17 @@
"""Link between a player and a tryout-specific team.""" """Link between a player and a tryout-specific team."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class TeamMember(db.Model): class TeamMember(db.Model):
"""Link between a player and a tryout-specific team.""" """Link between a player and a tryout-specific team."""
__tablename__ = 'team_members' __tablename__ = 'team_members'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False) team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
position = db.Column(db.String(50), nullable=True) position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow) added_at = db.Column(db.DateTime, default=utc_now_naive)
player = db.relationship('User', overlaps="player_ref,team_assignments") player = db.relationship('User', overlaps="player_ref,team_assignments")
+5 -3
View File
@@ -1,17 +1,19 @@
"""Team improvement notes from coach.""" """Team improvement notes from coach."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class TeamNote(db.Model): class TeamNote(db.Model):
"""Team improvement notes from coach.""" """Team improvement notes from coach."""
__tablename__ = 'team_notes' __tablename__ = 'team_notes'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) 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) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False) content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
team = db.relationship('OrgTeam', backref='team_notes') team = db.relationship('OrgTeam', backref='team_notes')
coach = db.relationship('User', foreign_keys=[coach_id]) coach = db.relationship('User', foreign_keys=[coach_id])
+1
View File
@@ -1,4 +1,5 @@
"""Tryout models.""" """Tryout models."""
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
from app.models.tryout.tryout_registration import TryoutRegistration from app.models.tryout.tryout_registration import TryoutRegistration
+29 -7
View File
@@ -1,11 +1,13 @@
"""Tryout event for player evaluations and team formation.""" """Tryout event for player evaluations and team formation."""
from app.extensions import db from app.extensions import db
from app.models._associations import tryout_coaches from app.models._associations import tryout_coaches, tryout_managers
from datetime import datetime from app.time_utils import utc_now_naive
class Tryout(db.Model): class Tryout(db.Model):
"""Tryout event for player evaluations and team formation.""" """Tryout event for player evaluations and team formation."""
__tablename__ = 'tryouts' __tablename__ = 'tryouts'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False) title = db.Column(db.String(200), nullable=False)
@@ -18,25 +20,45 @@ class Tryout(db.Model):
max_players = db.Column(db.Integer, nullable=True) max_players = db.Column(db.Integer, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) 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) target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration coach_id = db.Column(
created_at = db.Column(db.DateTime, default=datetime.utcnow) 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') creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts') coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts') coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m')
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic') registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic') evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
teams = db.relationship('Team', 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]) target_org_team = db.relationship(
'OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]
)
@property @property
def is_ended(self): def is_ended(self):
"""Tryout is considered ended after its end_date passes. """Tryout is considered ended after its end_date passes.
Falls back to date if end_date is not set.""" Falls back to date if end_date is not set."""
from datetime import date as date_type from datetime import date as date_type
today = date_type.today() today = date_type.today()
if self.end_date is not None: if self.end_date is not None:
return self.end_date < today return self.end_date < today
return self.date < today return self.date < today
def get_managers(self):
"""Managers attached to this tryout, both legacy and many-to-many."""
manager_list = list(self.managers)
if not manager_list and self.manager:
return [self.manager]
return manager_list
def get_coaches(self):
"""Coaches attached to this tryout, both legacy and many-to-many."""
coach_list = list(self.coaches)
if not coach_list and self.coach:
return [self.coach]
return coach_list
+4 -2
View File
@@ -1,14 +1,16 @@
"""Registration linking a player to a tryout.""" """Registration linking a player to a tryout."""
from app.extensions import db from app.extensions import db
from datetime import datetime from app.time_utils import utc_now_naive
class TryoutRegistration(db.Model): class TryoutRegistration(db.Model):
"""Registration linking a player to a tryout.""" """Registration linking a player to a tryout."""
__tablename__ = 'tryout_registrations' __tablename__ = 'tryout_registrations'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
registered_at = db.Column(db.DateTime, default=datetime.utcnow) registered_at = db.Column(db.DateTime, default=utc_now_naive)
status = db.Column(db.String(20), default='registered') status = db.Column(db.String(20), default='registered')
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
+8 -7
View File
@@ -1,11 +1,14 @@
"""Store gamertag per game for each user.""" """Store gamertag per game for each user."""
from app.extensions import db
from app.models._constants import TRN_URLS, PLATFORM_CODES, PLATFORM_DEFAULTS
from urllib.parse import quote from urllib.parse import quote
from app.extensions import db
from app.models._constants import PLATFORM_CODES, PLATFORM_DEFAULTS, TRN_URLS
class UserGamertag(db.Model): class UserGamertag(db.Model):
"""Store gamertag per game for each user.""" """Store gamertag per game for each user."""
__tablename__ = 'user_gamertags' __tablename__ = 'user_gamertags'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -15,9 +18,7 @@ class UserGamertag(db.Model):
user = db.relationship('User', backref='gamertags') user = db.relationship('User', backref='gamertags')
__table_args__ = ( __table_args__ = (db.UniqueConstraint('user_id', 'game', name='unique_user_game'),)
db.UniqueConstraint('user_id', 'game', name='unique_user_game'),
)
def get_trn_url(self): def get_trn_url(self):
if self.game not in TRN_URLS: if self.game not in TRN_URLS:
@@ -34,11 +35,11 @@ class UserGamertag(db.Model):
platform.lower().replace(' ', '-') if platform else '', platform.lower().replace(' ', '-') if platform else '',
) )
return url.format(platform_code=platform_code, username=encoded_gamertag) return url.format(platform_code=platform_code, username=encoded_gamertag)
elif '{platform}' in url and '{username}' in url: if '{platform}' in url and '{username}' in url:
return url.format( return url.format(
platform=platform.lower().replace(' ', '-') if platform else '', platform=platform.lower().replace(' ', '-') if platform else '',
username=encoded_gamertag, username=encoded_gamertag,
) )
elif '{username}' in url: if '{username}' in url:
return url.format(username=encoded_gamertag) return url.format(username=encoded_gamertag)
return url return url
+3 -2
View File
@@ -1,9 +1,10 @@
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout).""" """User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout)."""
from app.models.user_model.user import User
from app.models.user_model.admin import Admin from app.models.user_model.admin import Admin
from app.models.user_model.manager import Manager
from app.models.user_model.coach import Coach 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.player import Player
from app.models.user_model.scout import Scout from app.models.user_model.scout import Scout
from app.models.user_model.user import User
__all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout'] __all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout']
+3
View File
@@ -1,9 +1,11 @@
"""Admin / President — full access to everything.""" """Admin / President — full access to everything."""
from app.models.user_model.user import User from app.models.user_model.user import User
class Admin(User): class Admin(User):
"""President / super-admin — full access to everything.""" """President / super-admin — full access to everything."""
__mapper_args__ = {'polymorphic_identity': 'admin'} __mapper_args__ = {'polymorphic_identity': 'admin'}
def can_evaluate(self): def can_evaluate(self):
@@ -29,4 +31,5 @@ class Admin(User):
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
return Tryout.query.order_by(Tryout.date).all() return Tryout.query.order_by(Tryout.date).all()
+20 -32
View File
@@ -1,10 +1,20 @@
"""Coach — evaluates, schedules matches, manages their own org team.""" """Coach — evaluates, schedules matches, manages their own org teams."""
from app.models.user_model.user import User from app.models.user_model.user import User
from app.extensions import db
class Coach(User): class Coach(User):
"""Coach — evaluates, schedules matches, manages their own org team.""" """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'} __mapper_args__ = {'polymorphic_identity': 'coach'}
def can_evaluate(self): def can_evaluate(self):
@@ -17,38 +27,16 @@ class Coach(User):
return True return True
def can_manage_this_tryout(self, tryout): def can_manage_this_tryout(self, tryout):
from app.models.org_team.org_team import OrgTeam from app.permissions import coach_manages_tryout
if tryout.target_org_team_id:
is_coach_of_target = OrgTeam.query.filter( return coach_manages_tryout(self, tryout)
OrgTeam.id == tryout.target_org_team_id,
OrgTeam.coaches.any(id=self.id),
).first() is not None
if is_coach_of_target:
return True
# Check many-to-many coaches relationship
if any(c.id == self.id for c in tryout.coaches):
return True
# Backward compat: check deprecated coach_id
if tryout.coach_id == self.id:
return True
return False
def can_manage_this_org_team(self, org_team): def can_manage_this_org_team(self, org_team):
if org_team.coaches.filter_by(id=self.id).first(): if org_team.coaches.filter_by(id=self.id).first():
return True return True
if org_team.coach_id == self.id: return org_team.coach_id == self.id
return True
return False
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout from app.permissions import coach_tryouts
from app.models._associations import tryout_coaches
team_ids = [t.id for t in self.coached_org_teams.all()] return coach_tryouts(self)
conditions = []
if team_ids:
conditions.append(Tryout.target_org_team_id.in_(team_ids))
# Check many-to-many coaches
conditions.append(Tryout.coaches.any(id=self.id))
# Backward compat: check deprecated coach_id
conditions.append(Tryout.coach_id == self.id)
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
+21 -5
View File
@@ -1,9 +1,11 @@
"""Manager — manages own tryouts, all org teams, all contracts.""" """Manager — manages own tryouts, all org teams, all contracts."""
from app.models.user_model.user import User from app.models.user_model.user import User
class Manager(User): class Manager(User):
"""Manager — manages own tryouts, all org teams, all contracts.""" """Manager — manages own tryouts, all org teams, all contracts."""
__mapper_args__ = {'polymorphic_identity': 'manager'} __mapper_args__ = {'polymorphic_identity': 'manager'}
def can_evaluate(self): def can_evaluate(self):
@@ -19,14 +21,28 @@ class Manager(User):
return True return True
def can_manage_this_tryout(self, tryout): def can_manage_this_tryout(self, tryout):
return tryout.created_by == self.id or tryout.manager_id == self.id return (
tryout.created_by == self.id
or tryout.manager_id == self.id
or any(m.id == self.id for m in tryout.managers)
)
def can_manage_this_org_team(self, org_team): def can_manage_this_org_team(self, org_team):
return True return True
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
from sqlalchemy import or_ from sqlalchemy import or_
return Tryout.query.filter(
or_(Tryout.created_by == self.id, Tryout.manager_id == self.id) from app.models.tryout.tryout import Tryout
).order_by(Tryout.date).all()
return (
Tryout.query.filter(
or_(
Tryout.created_by == self.id,
Tryout.manager_id == self.id,
Tryout.managers.any(id=self.id),
)
)
.order_by(Tryout.date)
.all()
)
+25 -11
View File
@@ -1,30 +1,44 @@
"""Player — registers for tryouts, manages their own profile.""" """Player — registers for tryouts, manages their own profile."""
from app.models.user_model.user import User from app.models.user_model.user import User
class Player(User): class Player(User):
"""Player — registers for tryouts, manages their own profile.""" """Player — registers for tryouts, manages their own profile."""
__mapper_args__ = {'polymorphic_identity': 'player'} __mapper_args__ = {'polymorphic_identity': 'player'}
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
from app.models.match_model.match import Match from app.models.match_model.match import Match
from app.models.participant.match_participant import MatchParticipant from app.models.participant.match_participant import MatchParticipant
from app.models.tryout.tryout import Tryout
# tryouts they registered for # tryouts they registered for
player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()] player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()]
tryouts = Tryout.query.filter( tryouts = (
Tryout.id.in_(player_tryout_ids) Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all()
).order_by(Tryout.date).all() if player_tryout_ids else [] if player_tryout_ids
else []
)
# plus tryouts where they participate in a match # plus tryouts where they participate in a match
player_matches = Match.query.join(MatchParticipant).filter( player_matches = (
MatchParticipant.player_id == self.id, Match.query.join(MatchParticipant)
).all() .filter(
extra_ids = set(m.tryout_id for m in player_matches) MatchParticipant.player_id == self.id,
extra = Tryout.query.filter( )
Tryout.id.in_(extra_ids), .all()
).order_by(Tryout.date).all() if extra_ids else [] )
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} all_ids = {t.id for t in tryouts}
return tryouts + [t for t in extra if t.id not in all_ids] return tryouts + [t for t in extra if t.id not in all_ids]
+3
View File
@@ -1,9 +1,11 @@
"""Scout — view-only access to tryouts and evaluations.""" """Scout — view-only access to tryouts and evaluations."""
from app.models.user_model.user import User from app.models.user_model.user import User
class Scout(User): class Scout(User):
"""Scout — view-only access to tryouts and evaluations.""" """Scout — view-only access to tryouts and evaluations."""
__mapper_args__ = {'polymorphic_identity': 'scout'} __mapper_args__ = {'polymorphic_identity': 'scout'}
def can_evaluate(self): def can_evaluate(self):
@@ -11,4 +13,5 @@ class Scout(User):
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
return Tryout.query.order_by(Tryout.date).all() return Tryout.query.order_by(Tryout.date).all()
+28 -14
View File
@@ -1,7 +1,9 @@
"""Base User model — shared fields and polymorphic configuration.""" """Base User model — shared fields and polymorphic configuration."""
from app.extensions import db
from flask_login import UserMixin from flask_login import UserMixin
from datetime import datetime
from app.extensions import db
from app.time_utils import utc_now_naive
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
@@ -10,6 +12,7 @@ class User(UserMixin, db.Model):
Do not instantiate this class directly; use Admin, Manager, Coach, Player, Do not instantiate this class directly; use Admin, Manager, Coach, Player,
or Scout so that `polymorphic_identity` is set correctly. or Scout so that `polymorphic_identity` is set correctly.
""" """
__tablename__ = 'users' __tablename__ = 'users'
# --- columns ----------------------------------------------------------- # --- columns -----------------------------------------------------------
@@ -21,13 +24,13 @@ class User(UserMixin, db.Model):
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False)
phone = db.Column(db.String(20), nullable=True) phone = db.Column(db.String(20), nullable=True)
is_active_account = db.Column(db.Boolean, default=True) is_active_account = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
failed_login_attempts = db.Column(db.Integer, default=0) failed_login_attempts = db.Column(db.Integer, default=0)
locked_until = db.Column(db.DateTime, nullable=True) locked_until = db.Column(db.DateTime, nullable=True)
# E-Sports fields # E-Sports fields
games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player) games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player)
discord_username = db.Column(db.String(128), nullable=True) discord_username = db.Column(db.String(128), nullable=True)
discord_user_id = db.Column(db.String(64), nullable=True) discord_user_id = db.Column(db.String(64), nullable=True)
league_os_profile = db.Column(db.String(256), nullable=True) league_os_profile = db.Column(db.String(256), nullable=True)
@@ -40,16 +43,26 @@ class User(UserMixin, db.Model):
# --- relationships (defined once on the base) -------------------------- # --- relationships (defined once on the base) --------------------------
evaluations_given = db.relationship( evaluations_given = db.relationship(
'Evaluation', foreign_keys='Evaluation.evaluator_id', 'Evaluation', foreign_keys='Evaluation.evaluator_id', backref='evaluator', lazy='dynamic'
backref='evaluator', lazy='dynamic') )
evaluations_received = db.relationship( evaluations_received = db.relationship(
'Evaluation', foreign_keys='Evaluation.player_id', 'Evaluation', foreign_keys='Evaluation.player_id', backref='player', lazy='dynamic'
backref='player', lazy='dynamic') )
tryout_registrations = db.relationship( tryout_registrations = db.relationship('TryoutRegistration', backref='player', lazy='dynamic')
'TryoutRegistration', backref='player', lazy='dynamic')
team_assignments = db.relationship( team_assignments = db.relationship(
'TeamMember', foreign_keys='TeamMember.player_id', 'TeamMember', foreign_keys='TeamMember.player_id', backref='player_ref', lazy='dynamic'
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 --------------------------------------------- # --- shared helper methods ---------------------------------------------
def get_games_list(self): def get_games_list(self):
@@ -60,8 +73,9 @@ class User(UserMixin, db.Model):
def get_gamertags(self): def get_gamertags(self):
"""Return gamertags as a dict keyed by game.""" """Return gamertags as a dict keyed by game."""
return {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} return {
for gt in self.gamertags} gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in self.gamertags
}
def get_org_teams(self): def get_org_teams(self):
"""Return all OrgTeams this player belongs to.""" """Return all OrgTeams this player belongs to."""
+47 -11
View File
@@ -110,7 +110,8 @@ http {
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always; # X-XSS-Protection intentionally omitted: deprecated, removed from
# current browsers, and harmful in its last implementations.
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Opener-Policy "same-origin" always;
@@ -118,8 +119,17 @@ http {
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# Proxy to Waitress (Flask) # Proxy to Waitress (Flask)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
#
# 10000 is `PORT`'s default in wsgi.py, which is what serves this
# application in production. This line said 5000 — run.py's default,
# the development server — so anyone installing this file as shipped
# got 502 Bad Gateway on every page, from a configuration that looks
# entirely reasonable (STD-06).
#
# If PORT is set in the server's .env, this must match it.
# tests/test_nginx_config.py fails if this drifts from wsgi.py again.
location / { location / {
proxy_pass http://127.0.0.1:5000; proxy_pass http://127.0.0.1:10000;
# Proxy headers # Proxy headers
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -142,15 +152,41 @@ http {
} }
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# Static Files (served directly by Nginx for performance) # Static Files (PERF-006)
# Uncomment and adjust path if you want Nginx to serve static files #
# 59 KB of CSS and JS on every page load, previously proxied through
# Waitress. Nginx serves them from disk instead.
#
# ADJUST THIS ONE PATH to the deployment's checkout, absolute, forward
# slashes even on Windows. Nginx resolves a relative path against its
# own install prefix, not against this file. The trailing slash on both
# the location and the alias is required: without it /static/css/x.css
# resolves one directory too high.
#
# `immutable` is safe here and only here: url_for('static', …) appends
# ?v=<mtime> (see version_static_urls in app/app.py), so a deployed file
# is requested under a new URL and the cached copy of the old one is
# never asked for again. Removing that stamp and leaving this block
# gives every visitor a month-old stylesheet.
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# location /static/ { location /static/ {
# alias C:/path/to/team-tryouts/static/; alias C:/team-tryouts/app/static/;
# expires 30d; expires 30d;
# add_header Cache-Control "public, immutable"; access_log off;
# access_log off;
# } # These three are repeated on purpose. In nginx, add_header is
# inherited from the enclosing block ONLY when the current block
# declares none of its own — one add_header here silently drops
# every security header set at server level. Dropping nosniff on
# the JavaScript is the one that matters.
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# A missing static file must 404, not fall through to Flask: the
# fallthrough would hide a broken deploy behind a working page.
try_files $uri =404;
}
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# Rate Limiting # Rate Limiting
@@ -161,7 +197,7 @@ http {
# location /auth/login { # location /auth/login {
# limit_req zone=login burst=5 nodelay; # limit_req zone=login burst=5 nodelay;
# proxy_pass http://127.0.0.1:5000; # proxy_pass http://127.0.0.1:10000;
# } # }
} }
} }
+64
View File
@@ -0,0 +1,64 @@
"""Bounding what a list view loads (MNT-14).
Every list view ran `.all()` on its table and handed the whole thing to a
template. The audit rated the impact as nil — correctly, at the scale of a
student club — and recommended choosing the pattern now rather than
retro-fitting one later. This is that pattern, in one place, so that the
next list added to the application has something to copy.
Two decisions worth stating, because both are the kind that get made twice
differently otherwise.
**`error_out=False`.** Page numbers arrive in the URL, so `?page=999` is a
thing a person can type or a stale bookmark can hold. Flask-SQLAlchemy's
default answers it with a 404, which is a confusing thing to show someone
who has simply gone one page too far. An empty page is honest and the
controls take them back.
**A cap on `per_page`.** It is also a URL parameter, and without a ceiling
`?per_page=100000` re-creates by hand exactly the unbounded query this
module exists to prevent — the sort of thing that turns a listing into a
cheap way to make the server work hard.
"""
from flask import request, url_for
#: Rows per page when nothing asks otherwise.
DEFAULT_PER_PAGE = 50
#: Ceiling on the `per_page` query parameter. Generous enough that anyone
#: wanting "everything" on one screen gets it for any realistic table, low
#: enough that the query stays bounded.
MAX_PER_PAGE = 200
def paginate(query, per_page=DEFAULT_PER_PAGE):
"""Return one page of `query`, honouring `?page=` and `?per_page=`.
Args:
query: A SQLAlchemy query, already ordered. Ordering matters: a
paginated query without ORDER BY may return the same row on two
pages and never return another.
per_page: Default page size for this listing.
Returns:
flask_sqlalchemy.pagination.Pagination
"""
page = request.args.get('page', 1, type=int) or 1
requested = request.args.get('per_page', per_page, type=int) or per_page
size = max(1, min(requested, MAX_PER_PAGE))
return query.paginate(page=max(1, page), per_page=size, error_out=False)
def page_url(page):
"""URL of the current listing at another page number.
Rebuilt from the live request rather than composed in the template,
because the part that gets forgotten is the rest of the query string:
the evaluations list carries `sort` and `order`, the team matches list
carries `team_id`. A pagination link that drops them silently resets the
view the person was looking at.
"""
args = request.args.to_dict()
args.pop('page', None)
return url_for(request.endpoint, page=page, **(request.view_args or {}), **args)
+345
View File
@@ -0,0 +1,345 @@
"""Shared access-control rules — the single point of truth (ARCH-002).
Authorisation used to live inline in eight route modules, and the same
question could get a different answer depending on which URL you reached.
This module now holds the rules themselves; routes and models call it.
The subtlety it hides from callers: a coach or a manager can be attached to
a team two different ways.
OrgTeam.coach_id the original single-coach column
OrgTeam.coaches the many-to-many relationship added later
Both are still populated. Reading only ``coach_id`` — which most of
users.py did — silently locked out every coach who was not the first one on
their team, and reading ``.first()`` on top of it locked a coach out of
every team but one. Both defects were live in production. Every function
here considers both attachment routes and every team, so the fix applies
once instead of at each call site.
ARCH-001 will collapse the two columns into one for good; that needs a data
migration, so until then this module is what makes the duplication
harmless.
Functions take the acting user explicitly rather than reading
``current_user``: it keeps them callable from models, from the Discord bot,
and from tests without a request context.
"""
from app.extensions import db
# ---------------------------------------------------------------------------
# Team attachment
# ---------------------------------------------------------------------------
def coach_org_teams(coach):
"""Organisation teams a coach is attached to, ordered by name.
Considers the many-to-many relationship *and* the legacy column, so the
second coach of a team is not treated as belonging to nothing.
Args:
coach: The user to inspect.
Returns:
list[OrgTeam]: Possibly empty.
"""
from app.models import OrgTeam
return (
OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=coach.id),
OrgTeam.coach_id == coach.id,
)
)
.order_by(OrgTeam.name)
.all()
)
def coach_org_team_ids(coach):
"""IDs of the organisation teams a coach is attached to.
Args:
coach: The user to inspect.
Returns:
list[int]: Team IDs, possibly empty.
"""
return [team.id for team in coach_org_teams(coach)]
def manager_org_teams(manager):
"""Organisation teams a manager is attached to, ordered by name.
Symmetric to :func:`coach_org_teams`: ``manager_id`` and ``managers``
carry the same duplication.
Args:
manager: The user to inspect.
Returns:
list[OrgTeam]: Possibly empty.
"""
from app.models import OrgTeam
return (
OrgTeam.query.filter(
db.or_(
OrgTeam.managers.any(id=manager.id),
OrgTeam.manager_id == manager.id,
)
)
.order_by(OrgTeam.name)
.all()
)
def attached_org_teams(user):
"""Teams the user is personally attached to, whatever their role.
A president is attached to none in particular — they administer them
all — so this returns an empty list for them. Callers that want "the
teams to display" want :func:`visible_org_teams` instead.
Args:
user: The acting user.
Returns:
list[OrgTeam]: Possibly empty.
"""
from app.models import Coach, Manager, Player
if isinstance(user, Coach):
return coach_org_teams(user)
if isinstance(user, Manager):
return manager_org_teams(user)
if isinstance(user, Player):
return user.get_org_teams()
return []
def visible_org_teams(user):
"""Organisation teams the user may see, ordered by name.
A president sees every team; a coach or a manager sees the ones they
are attached to; a player sees the ones they play on; anyone else sees
none.
Args:
user: The acting user.
Returns:
list[OrgTeam]: Possibly empty.
"""
from app.models import Admin, OrgTeam
if isinstance(user, Admin):
return OrgTeam.query.order_by(OrgTeam.name).all()
return attached_org_teams(user)
def can_manage_org_team(user, org_team):
"""Whether the user may administer this organisation team.
Delegates to the polymorphic model method, which is the role-level
rule; this wrapper exists so route code has one name to call and never
has to know which subclass it is holding.
Args:
user: The acting user.
org_team: The team concerned.
Returns:
bool
"""
return bool(org_team) and user.can_manage_this_org_team(org_team)
# ---------------------------------------------------------------------------
# Reach over players
# ---------------------------------------------------------------------------
def org_team_player_ids(team_ids):
"""IDs of the players placed on any of these teams.
Args:
team_ids: Team primary keys.
Returns:
list[int]: Player IDs, possibly empty, without duplicates.
"""
from app.models import TeamPlayer
if not team_ids:
return []
rows = TeamPlayer.query.filter(TeamPlayer.org_team_id.in_(team_ids)).all()
return list({row.player_id for row in rows})
def coach_player_ids(coach):
"""IDs of the players on *all* of a coach's teams.
The routes this replaces looked at one team — the first row matching
the legacy column — so a coach of two teams could act on half of their
squad and no more.
Args:
coach: The acting coach.
Returns:
list[int]: Player IDs, possibly empty.
"""
return org_team_player_ids(coach_org_team_ids(coach))
def coach_can_access_player(coach, player_id):
"""Whether a coach may read or write information about a player.
True when the player sits on one of the coach's teams, or takes part in
a tryout the coach manages. Anything else means the two have no working
relationship, and a note or a contract about that player is none of the
coach's business.
Args:
coach: The acting coach.
player_id: Primary key of the player concerned.
Returns:
bool
"""
from app.models import Match, MatchParticipant, TeamPlayer, TryoutRegistration
if not player_id:
return False
team_ids = coach_org_team_ids(coach)
if team_ids:
on_team = TeamPlayer.query.filter(
TeamPlayer.player_id == player_id,
TeamPlayer.org_team_id.in_(team_ids),
).first()
if on_team:
return True
tryout_ids = coach_tryout_ids(coach, team_ids=team_ids)
if not tryout_ids:
return False
registered = TryoutRegistration.query.filter(
TryoutRegistration.player_id == player_id,
TryoutRegistration.tryout_id.in_(tryout_ids),
).first()
if registered:
return True
plays_a_match = (
MatchParticipant.query.join(Match)
.filter(
MatchParticipant.player_id == player_id,
Match.tryout_id.in_(tryout_ids),
)
.first()
)
return plays_a_match is not None
def can_manage_player_contract(user, player_id):
"""Whether the user may upload or replace a contract for this player.
Presidents and managers may do so for anyone. A coach may do so for the
players on their teams — the working relationship a contract implies.
Args:
user: The acting user.
player_id: Primary key of the player concerned.
Returns:
bool
"""
from app.models import Admin, Coach, Manager
if not player_id:
return False
if isinstance(user, (Admin, Manager)):
return True
if isinstance(user, Coach):
return player_id in coach_player_ids(user)
return False
# ---------------------------------------------------------------------------
# Tryouts
# ---------------------------------------------------------------------------
def coach_tryouts(coach, team_ids=None):
"""Tryouts a coach manages, ordered by date.
A coach reaches a tryout through any of the three routes the model
supports: it targets one of their teams, they are named on the
many-to-many relationship, or the deprecated ``coach_id`` points at
them.
Args:
coach: The acting coach.
team_ids: Pre-computed team IDs, to avoid querying twice when the
caller already has them.
Returns:
list[Tryout]: Possibly empty.
"""
from app.models import Tryout
if team_ids is None:
team_ids = coach_org_team_ids(coach)
conditions = [
Tryout.coaches.any(id=coach.id),
Tryout.coach_id == coach.id,
]
if team_ids:
conditions.append(Tryout.target_org_team_id.in_(team_ids))
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
def coach_tryout_ids(coach, team_ids=None):
"""IDs of the tryouts a coach manages.
Args:
coach: The acting coach.
team_ids: Pre-computed team IDs, see :func:`coach_tryouts`.
Returns:
list[int]: Possibly empty.
"""
return [tryout.id for tryout in coach_tryouts(coach, team_ids=team_ids)]
def coach_manages_tryout(coach, tryout):
"""Whether a coach manages this particular tryout.
Same three routes as :func:`coach_tryouts`, asked about one row.
Written as a membership test rather than a query so that a tryout not
yet flushed to the database still answers correctly.
Args:
coach: The acting coach.
tryout: The tryout concerned.
Returns:
bool
"""
if tryout is None:
return False
if tryout.coach_id == coach.id:
return True
if any(c.id == coach.id for c in tryout.coaches):
return True
if tryout.target_org_team_id:
return tryout.target_org_team_id in coach_org_team_ids(coach)
return False
+67 -45
View File
@@ -19,17 +19,16 @@ from app.models import (
Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch, Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch,
TeamMatchParticipant, AppSettings, BackupRecord, AuditLog, TeamMatchParticipant, AppSettings, BackupRecord, AuditLog,
) )
from app.supporting_scripts import backup as backup_helper from app.supporting_scripts.backup import (
BACKUP_DIR, BackupError, backup_database, backup_documents,
create_backup_dir, parse_database_url, verify_backup,
)
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def require_admin(): def require_admin():
"""Return True if current user is an Admin, else flash and redirect. """Return True if current user is an Admin, else flash and redirect."""
This returns False for non-admins so the caller can stop processing,
but the redirect/abort should be handled by the caller.
"""
if isinstance(current_user, Admin): if isinstance(current_user, Admin):
return True return True
flash('Only the president can access the admin panel.', 'danger') flash('Only the president can access the admin panel.', 'danger')
@@ -96,6 +95,27 @@ def dashboard():
# Backups # Backups
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _create_backup_record(backup_type='manual', notes=None):
"""Run a database backup and return a BackupRecord, or raise BackupError."""
create_backup_dir()
conn = parse_database_url(os.getenv('DATABASE_URL'))
file_path = backup_database(conn)
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
filename = os.path.basename(file_path)
record = BackupRecord(
filename=filename,
file_path=file_path,
size_bytes=size,
backup_type=backup_type,
notes=notes,
created_by_id=current_user.id,
)
db.session.add(record)
db.session.commit()
return record
@admin_bp.route('/backup/create', methods=['POST']) @admin_bp.route('/backup/create', methods=['POST'])
@login_required @login_required
def create_backup(): def create_backup():
@@ -105,25 +125,14 @@ def create_backup():
notes = request.form.get('notes', '').strip() or None notes = request.form.get('notes', '').strip() or None
try: try:
result = backup_helper.create_backup(backup_type='manual', notes=notes) record = _create_backup_record(backup_type='manual', notes=notes)
except RuntimeError as e: except BackupError as e:
flash(f'Backup failed: {e}', 'danger') flash(f'Backup failed: {e}', 'danger')
_log('backup_failed', f'Error: {e}') _log('backup_failed', f'Error: {e}')
return redirect(url_for('admin.dashboard')) return redirect(url_for('admin.dashboard'))
record = BackupRecord( _log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)')
filename=result['filename'], flash(f'Backup created successfully: {record.filename}', 'success')
file_path=result['file_path'],
size_bytes=result['size_bytes'],
backup_type='manual',
notes=notes,
created_by_id=current_user.id,
)
db.session.add(record)
db.session.commit()
_log('backup_created', f'File: {result["filename"]} ({result["size_bytes"]} bytes)')
flash(f'Backup created successfully: {result["filename"]}', 'success')
return redirect(url_for('admin.dashboard')) return redirect(url_for('admin.dashboard'))
@@ -171,7 +180,7 @@ def restore_backup(backup_id):
"""Restore a selected backup. """Restore a selected backup.
A safety backup of the current state is created first, then the A safety backup of the current state is created first, then the
selected dump is restored via psql. selected dump is restored via pg_restore.
""" """
if not require_admin(): if not require_admin():
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
@@ -183,30 +192,50 @@ def restore_backup(backup_id):
# Create a safety backup of the current state before restoring. # Create a safety backup of the current state before restoring.
try: try:
safety = backup_helper.create_backup(backup_type='pre_restore', notes='Pre-restore safety backup') safety = _create_backup_record(
safety_record = BackupRecord( backup_type='pre_restore',
filename=safety['filename'],
file_path=safety['file_path'],
size_bytes=safety['size_bytes'],
backup_type='auto',
notes='Automatic safety backup before restoring ' + record.filename, notes='Automatic safety backup before restoring ' + record.filename,
created_by_id=current_user.id,
) )
db.session.add(safety_record) except BackupError as e:
db.session.commit()
except RuntimeError as e:
flash(f'Could not create safety backup, restore aborted: {e}', 'danger') flash(f'Could not create safety backup, restore aborted: {e}', 'danger')
return redirect(url_for('admin.dashboard')) return redirect(url_for('admin.dashboard'))
# Restore using pg_restore
import subprocess
from app.supporting_scripts.backup import (
PG_RESTORE, dump_environment, parse_database_url,
)
conn = parse_database_url(os.getenv('DATABASE_URL'))
cmd = [
PG_RESTORE,
'--host', conn['host'],
'--port', conn['port'],
'--username', conn['user'],
'--dbname', conn['dbname'],
'--clean', '--if-exists', '--no-owner',
record.file_path,
]
try: try:
backup_helper.restore_backup(record.file_path) result = subprocess.run(
except RuntimeError as e: cmd,
env=dump_environment(conn),
capture_output=True,
text=True,
timeout=900,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or 'pg_restore failed')
except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
flash(f'Restore failed: {e}', 'danger') flash(f'Restore failed: {e}', 'danger')
_log('backup_restore_failed', f'File: {record.filename}, Error: {e}') _log('backup_restore_failed', f'File: {record.filename}, Error: {e}')
return redirect(url_for('admin.dashboard')) return redirect(url_for('admin.dashboard'))
_log('backup_restored', f'File: {record.filename}') _log('backup_restored', f'File: {record.filename}')
flash(f'Backup {record.filename} restored successfully. The database has been rolled back.', 'success') flash(
f'Backup {record.filename} restored successfully. The database has been rolled back.',
'success',
)
return redirect(url_for('admin.dashboard')) return redirect(url_for('admin.dashboard'))
@@ -322,18 +351,11 @@ def wipe_teams():
# Safety backup before destructive operation # Safety backup before destructive operation
try: try:
safety = backup_helper.create_backup(backup_type='pre_wipe', notes='Pre-wipe safety backup') _create_backup_record(
safety_record = BackupRecord( backup_type='pre_wipe',
filename=safety['filename'],
file_path=safety['file_path'],
size_bytes=safety['size_bytes'],
backup_type='auto',
notes='Automatic safety backup before wiping teams', notes='Automatic safety backup before wiping teams',
created_by_id=current_user.id,
) )
db.session.add(safety_record) except BackupError as e:
db.session.commit()
except RuntimeError as e:
flash(f'Wipe aborted — could not create safety backup: {e}', 'danger') flash(f'Wipe aborted — could not create safety backup: {e}', 'danger')
return redirect(url_for('admin.dashboard')) return redirect(url_for('admin.dashboard'))
+415 -177
View File
@@ -2,24 +2,59 @@
This module handles user authentication including login with account lockout This module handles user authentication including login with account lockout
protection, logout with session clearing, and new user registration with protection, logout with session clearing, and new user registration with
password policy enforcement and CAPTCHA verification. password policy enforcement and sign-up screening.
""" """
import uuid
import os import os
from datetime import datetime, timedelta import secrets
from flask import Blueprint, render_template, redirect, url_for, flash, request, session import time
from flask_login import login_user, logout_user, login_required, current_user from datetime import timedelta
from app.extensions import db, hash_password, check_password, limiter from urllib.parse import urlencode, urlparse
from app.models import User, Player, ESPORT_GAMES
from app.validators import RegisterSchema, LoginSchema
from marshmallow import ValidationError
from urllib.parse import urlparse
import requests
# Account lockout settings 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 MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15 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 OAuth2 configuration
DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID') DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID')
@@ -40,53 +75,124 @@ DISCORD_PLATFORM_TO_GAMES = {
def is_safe_url(url): def is_safe_url(url):
"""Validate that a URL is safe for redirection (same origin). """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: Args:
url: The URL to validate. url: The URL to validate.
Returns: Returns:
bool: True if the URL is safe (relative or same origin). bool: True if the URL is safe.
""" """
if not url: if not url:
return False return False
if any(ord(char) < 0x20 or char in '\\\x7f' for char in url):
return False
parsed = urlparse(url) parsed = urlparse(url)
# Allow relative URLs (no netloc) or same-origin URLs if parsed.netloc:
return not parsed.netloc or parsed.netloc == request.host 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 generate_captcha(): def _absent_user_hash():
"""Generate a simple math CAPTCHA challenge. """A hash to verify against when the submitted username does not exist.
Creates a random addition problem and stores the answer in the session. 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
Returns: one as slowly as scrypt. The gap is measurable and enumerates accounts.
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys. Computed once per process, from a random secret, so no submitted password
can ever match it.
""" """
import random global _ABSENT_USER_HASH
a = random.randint(1, 10) if _ABSENT_USER_HASH is None:
b = random.randint(1, 10) _ABSENT_USER_HASH = hash_password(secrets.token_urlsafe(32))
captcha_id = str(uuid.uuid4()) return _ABSENT_USER_HASH
session['captcha_id'] = captcha_id
session['captcha_answer'] = a + b
return {'question': f'{a} + {b} = ?', 'id': captcha_id}
def verify_captcha(user_answer): def cooloff_minutes(failed_attempts):
"""Verify the CAPTCHA answer from the session. """Length of the cool-off window earned by this many failed attempts.
Doubles every MAX_LOGIN_ATTEMPTS further failures, up to a ceiling.
Args: Args:
user_answer: The user's submitted answer (string or int). failed_attempts: Consecutive failures recorded on the account.
Returns: Returns:
bool: True if the answer matches the stored CAPTCHA, False otherwise. int: Minutes.
""" """
try: steps = max(failed_attempts // MAX_LOGIN_ATTEMPTS - 1, 0)
expected = session.pop('captcha_answer', None) return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES)
session.pop('captcha_id', None)
if expected is None:
return False def issue_registration_challenge():
return int(user_answer) == expected """Mark that the registration form has been handed out, and when.
except (ValueError, TypeError):
return False 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 = Blueprint('auth', __name__, url_prefix='/auth')
@@ -95,16 +201,17 @@ auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@auth_bp.route('/login', methods=['GET', 'POST']) @auth_bp.route('/login', methods=['GET', 'POST'])
@limiter.limit("10 per minute") @limiter.limit("10 per minute")
def login(): def login():
"""Handle user login authentication with account lockout protection. """Handle user login authentication.
GET: Render the login form. GET: Render the login form.
POST: Authenticate user credentials with lockout check and audit logging. POST: Authenticate user credentials, with audit logging.
Account lockout: After 5 consecutive failed attempts, the account is Failed attempts are counted and open a cool-off window, recorded in
locked for 15 minutes. Successful login resets the counter. ``locked_until`` and in the authentication log. The window does not
refuse correct credentials: when it did, five wrong guesses against a
Redirects authenticated users to dashboard. Validates credentials and checks known username took that account out of service for fifteen minutes,
account status before login. 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: Returns:
Response: Login form or redirect to dashboard/next page. Response: Login form or redirect to dashboard/next page.
@@ -120,80 +227,124 @@ def login():
except ValidationError as err: except ValidationError as err:
for field, messages in err.messages.items(): for field, messages in err.messages.items():
for msg in messages: for msg in messages:
flash(f'{field}: {msg}', 'danger') flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return render_template('pages/login.html') return render_template('pages/login.html')
username = validated['username'] username = validated['username']
password = validated['password'] password = validated['password']
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
# Check if account is locked # Verified before anything else is decided, and on both branches.
if user and user.locked_until and user.locked_until > datetime.utcnow(): # Reaching this only when a row exists made the response time a
remaining = (user.locked_until - datetime.utcnow()).seconds // 60 # reliable oracle for which usernames are registered (SEC-017).
flash( credentials_ok = check_password(
f'Account is locked due to too many failed attempts. ' user.password_hash if user else _absent_user_hash(), password
f'Please try again in {remaining} minute(s).', )
'danger'
)
return render_template('pages/login.html')
if user and check_password(user.password_hash, password): if user and credentials_ok:
if not user.is_active_account: if not user.is_active_account:
flash('This account has been deactivated.', 'danger') 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') return render_template('pages/login.html')
# Reset failed login attempts on successful login # 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.failed_login_attempts = 0
user.locked_until = None user.locked_until = None
db.session.commit() db.session.commit()
# Clear old session data and preserve CSRF token to prevent # Clear old session data and preserve CSRF token to prevent
# session fixation attacks (Flask-Login rotates the session ID) # session fixation attacks (Flask-Login rotates the session ID).
_csrf_token = session.get('csrf_token') #
# 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.clear()
if _csrf_token: session.update(_preserved)
session['csrf_token'] = _csrf_token
# 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) 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 # Validate redirect URL to prevent open redirect vulnerability
next_page = request.args.get('next') next_page = request.args.get('next')
if next_page and not is_safe_url(next_page): if next_page and not is_safe_url(next_page):
next_page = None next_page = None
flash(f'Welcome back, {user.username}!', 'success') flash(_('Welcome back, %(username)s!', username=user.username), 'success')
return redirect(next_page) if next_page else redirect(url_for('main.dashboard')) 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: else:
# Track failed login attempt log_auth_event('login.failure.unknown_user', username=username)
if user:
user.failed_login_attempts += 1 flash(
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS: _(
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES) 'Login unsuccessful. Please check your username and '
flash( 'password, or ask a president for help.'
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. ' ),
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.', 'danger',
'danger' )
)
else:
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
flash(
f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.',
'danger'
)
db.session.commit()
else:
flash('Login unsuccessful. Please check username and password.', 'danger')
return render_template('pages/login.html') 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']) @auth_bp.route('/register', methods=['GET', 'POST'])
@limiter.limit("20 per hour") @limiter.limit("20 per hour")
def register(): def register():
"""Handle new player registration with CAPTCHA and password policy. """Handle new player registration.
GET: Render the registration form with E-Sports games list and CAPTCHA. GET: Render the registration form with the E-Sports games list.
POST: Validate all inputs, verify CAPTCHA, enforce password policy, POST: Screen the submission (see check_registration_challenge), validate
and create a new player account. every input against RegisterSchema, and create a new player account.
Only players can register through this form. Validates username/email Only players can register through this form. Validates username/email
uniqueness and password confirmation. uniqueness and password confirmation.
@@ -209,20 +360,24 @@ def register():
form_data = dict(request.form) form_data = dict(request.form)
form_data['games'] = request.form.getlist('games') form_data['games'] = request.form.getlist('games')
# Validate CAPTCHA first # Once Discord has authenticated the identity, neither its display
captcha_answer = request.form.get('captcha_answer', '') # name nor its snowflake is input data anymore. Remove any client
if not verify_captcha(captcha_answer): # copies before validation as well as before persistence: otherwise a
flash('Incorrect CAPTCHA answer. Please try again.', 'danger') # forged, malformed hidden value can still make the verified flow fail.
captcha = generate_captcha() discord_oauth = session.get('discord_oauth') or {}
# Clear password fields only on CAPTCHA failure if discord_oauth.get('id'):
form_data.pop('password', None) form_data.pop('discord_username', None)
form_data.pop('confirm_password', None) form_data.pop('discord_user_id', None)
return render_template(
'pages/register.html', refusal = check_registration_challenge(request.form)
esport_games=ESPORT_GAMES, if refusal is not None:
captcha=captcha, # Logged, because this is the only place abuse of the sign-up
form_data=form_data, # 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 # Validate input with marshmallow schema
register_schema = RegisterSchema() register_schema = RegisterSchema()
@@ -231,17 +386,8 @@ def register():
except ValidationError as err: except ValidationError as err:
for field, messages in err.messages.items(): for field, messages in err.messages.items():
for msg in messages: for msg in messages:
flash(f'{field}: {msg}', 'danger') flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
captcha = generate_captcha() return _rerender_registration(form_data)
# Clear password fields on validation failure
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha,
form_data=form_data,
)
username = validated['username'] username = validated['username']
email = validated['email'] email = validated['email']
@@ -249,33 +395,41 @@ def register():
full_name = validated['full_name'] full_name = validated['full_name']
phone = validated.get('phone') phone = validated.get('phone')
selected_games = validated.get('games', []) selected_games = validated.get('games', [])
discord_username = validated.get('discord_username') try:
discord_user_id = validated.get('discord_user_id') 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') league_os_profile = validated.get('league_os_profile')
if User.query.filter_by(username=username).first(): if User.query.filter_by(username=username).first():
flash('Username already exists.', 'danger') flash(_('Username already exists.'), 'danger')
captcha = generate_captcha() return _rerender_registration(form_data)
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha,
form_data=form_data,
)
if User.query.filter_by(email=email).first(): if User.query.filter_by(email=email).first():
flash('Email already registered.', 'danger') flash(_('Email already registered.'), 'danger')
captcha = generate_captcha() return _rerender_registration(form_data)
form_data.pop('password', None)
form_data.pop('confirm_password', None) # The database constraint belongs to DB-002, after production has
return render_template( # been backed up and deduplicated. Refuse new duplicates now instead
'pages/register.html', # of leaving the critical impersonation path open until then.
esport_games=ESPORT_GAMES, if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
captcha=captcha, flash(_('This Discord account is already linked to another account.'), 'danger')
form_data=form_data, return _rerender_registration(form_data)
)
hashed_password = hash_password(password) hashed_password = hash_password(password)
user = Player( user = Player(
@@ -291,36 +445,37 @@ def register():
league_os_profile=league_os_profile, league_os_profile=league_os_profile,
) )
db.session.add(user) db.session.add(user)
db.session.commit() # 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 # Create UserGamertag records for each selected game
from app.models import UserGamertag from app.models import UserGamertag
for game in selected_games:
field_name = f'gamertag_{game}' for game, gamertag_data in submitted_gamertags.items():
gamertag_value = request.form.get(field_name, '').strip() gamertag = UserGamertag(
if gamertag_value: user_id=user.id,
gamertag = UserGamertag( game=game,
user_id=user.id, gamertag=gamertag_data['gamertag'],
game=game, platform=gamertag_data['platform'],
gamertag=gamertag_value, )
) db.session.add(gamertag)
db.session.add(gamertag)
db.session.commit() db.session.commit()
# Clear Discord OAuth data from session after successful registration # Clear Discord OAuth data from session after successful registration
session.pop('discord_oauth', None) session.pop('discord_oauth', None)
session.pop(REGISTRATION_ISSUED_KEY, None)
flash('Your account has been created! You can now log in.', 'success') 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')) return redirect(url_for('auth.login'))
# GET request — render empty form # GET request — render empty form
captcha = generate_captcha() issue_registration_challenge()
return render_template( return _rerender_registration({})
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha,
form_data={},
)
@auth_bp.route('/discord/login') @auth_bp.route('/discord/login')
@@ -333,18 +488,31 @@ def discord_login():
Returns: Returns:
Response: Redirect to Discord authorization URL. Response: Redirect to Discord authorization URL.
""" """
if not DISCORD_CLIENT_ID: purpose = 'profile' if current_user.is_authenticated else 'registration'
flash('Discord OAuth2 is not configured.', 'danger') session[DISCORD_PURPOSE_KEY] = purpose
return redirect(url_for('auth.register')) 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 = { params = {
'client_id': DISCORD_CLIENT_ID, 'client_id': DISCORD_CLIENT_ID,
'redirect_uri': DISCORD_REDIRECT_URI, 'redirect_uri': DISCORD_REDIRECT_URI,
'response_type': 'code', 'response_type': 'code',
'scope': 'identify connections', 'scope': 'identify connections',
'state': state,
} }
query = '&'.join(f'{k}={requests.utils.quote(v)}' for k, v in params.items()) auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{urlencode(params)}'
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{query}'
return redirect(auth_url) return redirect(auth_url)
@@ -352,18 +520,41 @@ def discord_login():
def discord_callback(): def discord_callback():
"""Handle the OAuth2 callback from Discord. """Handle the OAuth2 callback from Discord.
Exchanges the authorization code for an access token, then fetches Exchanges the authorization code for an access token, then fetches the
the user's profile (/users/@me) and connections (/users/@me/connections). user's profile. During registration, connected game accounts are also
Results are stored in the session and the user is redirected back to loaded into server-side draft state. For a signed-in profile relink, the
the registration form where fields will be pre-filled. verified identity is written directly without passing through a form.
Returns: Returns:
Response: Redirect to registration page. 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') code = request.args.get('code')
if not code: if not code:
flash('Discord authorization failed. No code received.', 'danger') flash(_('Discord authorization failed. No code received.'), 'danger')
return redirect(url_for('auth.register')) return redirect(url_for(return_endpoint))
# Exchange the authorization code for an access token # Exchange the authorization code for an access token
token_data = { token_data = {
@@ -385,13 +576,13 @@ def discord_callback():
token_response.raise_for_status() token_response.raise_for_status()
token_json = token_response.json() token_json = token_response.json()
access_token = token_json.get('access_token') access_token = token_json.get('access_token')
except requests.RequestException as e: except requests.RequestException:
flash(f'Failed to connect to Discord. Please try again.', 'danger') flash(_('Failed to connect to Discord. Please try again.'), 'danger')
return redirect(url_for('auth.register')) return redirect(url_for(return_endpoint))
if not access_token: if not access_token:
flash('Failed to obtain Discord access token.', 'danger') flash(_('Failed to obtain Discord access token.'), 'danger')
return redirect(url_for('auth.register')) return redirect(url_for(return_endpoint))
auth_headers = {'Authorization': f'Bearer {access_token}'} auth_headers = {'Authorization': f'Bearer {access_token}'}
@@ -405,8 +596,44 @@ def discord_callback():
user_response.raise_for_status() user_response.raise_for_status()
user_data = user_response.json() user_data = user_response.json()
except requests.RequestException: except requests.RequestException:
flash('Failed to fetch Discord user profile.', 'danger') flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for('auth.register')) 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 # Fetch the user's connected gaming accounts
connections = [] connections = []
@@ -445,29 +672,40 @@ def discord_callback():
# Store in session for the registration form to use # Store in session for the registration form to use
session['discord_oauth'] = { session['discord_oauth'] = {
'id': user_data.get('id'), 'id': discord_user_id,
'username': user_data.get('username'), 'username': user_data.get('username'),
'avatar': user_data.get('avatar'), 'avatar': user_data.get('avatar'),
'gamertag_suggestions': gamertag_suggestions, 'gamertag_suggestions': gamertag_suggestions,
'auto_select_games': auto_select_games, 'auto_select_games': auto_select_games,
} }
flash('Discord account connected! Your profile has been pre-filled.', 'success') flash(_('Discord account connected! Your profile has been pre-filled.'), 'success')
return redirect(url_for('auth.register')) return redirect(url_for('auth.register'))
@auth_bp.route('/logout') @auth_bp.route('/logout', methods=['POST'])
@login_required @login_required
def logout(): def logout():
"""Log out the current user and clear the session. """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 Clears the user session and regenerates session ID to prevent
session fixation/replay after logout. session fixation/replay after logout.
Returns: Returns:
Response: Redirect to login page with logout message. Response: Redirect to login page with logout message.
""" """
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
logout_user() 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() session.clear()
flash('You have been logged out.', 'info') if _locale:
session[LOCALE_SESSION_KEY] = _locale
flash(_('You have been logged out.'), 'info')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
+250 -137
View File
@@ -3,31 +3,44 @@
Uses polymorphic isinstance checks instead of role-string comparisons. 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 import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Admin, Coach, Manager, Player, Admin, Coach, Manager, Player,
User, Tryout, Evaluation, TryoutRegistration, User, Tryout, Evaluation, TryoutRegistration,
OrgTeam, GAME_POSITIONS, OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
) )
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
GAME_POSITIONS,
Admin,
Evaluation,
Player,
Tryout,
TryoutRegistration,
User,
)
from app.pagination import paginate
from app.validators import EvaluationSchema
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
def validate_score(score_value): def _users_by_id(user_ids):
"""Validate that a score is between 1 and 10.""" """Load a set of users once for aggregate/list views."""
if score_value is None: wanted = {user_id for user_id in user_ids if user_id}
return None if not wanted:
try: return {}
score = int(score_value) return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
if 1 <= score <= 10:
return score
return None
except (ValueError, TypeError):
return None
@evaluations_bp.route('') @evaluations_bp.route('')
@@ -37,7 +50,7 @@ def list_evaluations():
user = current_user user = current_user
if isinstance(user, Player): if isinstance(user, Player):
flash('You do not have permission to view evaluations.', 'danger') flash(_('You do not have permission to view evaluations.'), 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
sort_column = request.args.get('sort', 'created_at') sort_column = request.args.get('sort', 'created_at')
@@ -73,50 +86,183 @@ def list_evaluations():
sort_expr = sort_expr.desc() sort_expr = sort_expr.desc()
if isinstance(user, Admin): if isinstance(user, Admin):
evaluations = Evaluation.query \ evaluations_page = paginate(
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \ Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \ .outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \ .outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.order_by(sort_expr).all() .order_by(sort_expr, Evaluation.id)
avg_scores = db.session.query( )
Evaluation.player_id, avg_scores = (
func.count(Evaluation.id).label('eval_count'), db.session.query(
func.avg(Evaluation.overall_score).label('avg_score'), Evaluation.player_id,
).group_by(Evaluation.player_id).all() 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 = {} player_scores = {}
for row in avg_scores: for row in avg_scores:
p = User.query.get(row.player_id) p = players_by_id.get(row.player_id)
if p: if p:
player_scores[p.id] = { player_scores[p.id] = {
'player': p, 'count': row.eval_count, 'player': p,
'count': row.eval_count,
'avg': round(row.avg_score, 1) if row.avg_score else 0, 'avg': round(row.avg_score, 1) if row.avg_score else 0,
} }
elif user.can_evaluate():
evaluations = 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).all()
player_scores = {}
else: else:
evaluations = Evaluation.query \ # Everyone still here evaluates: players were redirected above, and
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \ # can_evaluate() is true for the four remaining roles. The former
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \ # `else` branch listed evaluations *received* — a player's view,
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \ # unreachable from this point (ARCH-007).
.filter(Evaluation.player_id == user.id) \ evaluations_page = paginate(
.order_by(sort_expr).all() 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 = {} player_scores = {}
return render_template('pages/evaluations.html', return render_template(
evaluations=evaluations, player_scores=player_scores, 'pages/evaluations.html',
sort_column=sort_column, sort_order=sort_order) 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']) @evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
@login_required @login_required
def evaluate_player(tryout_id, player_id): def evaluate_player(tryout_id, player_id):
"""Evaluate a specific player in a tryout.""" """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(): if not current_user.can_evaluate():
flash('You do not have permission to evaluate players.', 'danger') flash('You do not have permission to evaluate players.', 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
@@ -126,108 +272,75 @@ def evaluate_player(tryout_id, player_id):
flash('You do not have permission to evaluate players in this tryout.', 'danger') flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
is_registered = TryoutRegistration.query.filter_by( # Resolve selected player ids (query string on GET, hidden fields on POST).
tryout_id=tryout_id, player_id=player_id, player_ids = []
).first() is not None for raw in request.values.getlist('player_ids'):
if not is_registered: try:
flash('Player is not registered for this tryout.', 'danger') pid = int(raw)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) except (ValueError, TypeError):
continue
if pid not in player_ids:
player_ids.append(pid)
player = User.query.get_or_404(player_id) if not player_ids:
if not isinstance(player, Player): flash('Please select at least one player to evaluate.', 'warning')
flash('Can only evaluate players.', 'danger') return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing_eval = Evaluation.query.filter_by( players = []
tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id, for pid in player_ids:
).first() 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': if request.method == 'POST':
mecanics = validate_score(request.form.get('mecanics_score')) saved = 0
cohesion = validate_score(request.form.get('cohesion_score')) for entry in players:
communication = validate_score(request.form.get('communication_score')) pid = entry['player'].id
gamesense = validate_score(request.form.get('gamesense_score')) scores = {
versatility = validate_score(request.form.get('versatility_score')) field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
discipline = validate_score(request.form.get('discipline_score')) for field_name, _ in EVALUATION_CRITERIA
analysis = validate_score(request.form.get('analysis_score')) }
sport_ethics = validate_score(request.form.get('sport_ethics_score')) comments = request.form.get(f'comments_{pid}')
mental = validate_score(request.form.get('mental_score')) position = request.form.get(f'position_recommendation_{pid}')
comments = request.form.get('comments')
position = request.form.get('position_recommendation')
scores = [s for s in [mecanics, cohesion, communication, gamesense, existing = entry['existing']
versatility, discipline, analysis, sport_ethics, mental] if existing:
if s is not None] _apply_evaluation(existing, scores, comments, position)
overall = sum(scores) / len(scores) if scores else None else:
evaluation = Evaluation(
if existing_eval: tryout_id=tryout_id, player_id=pid,
existing_eval.mecanics_score = mecanics evaluator_id=current_user.id,
existing_eval.cohesion_score = cohesion )
existing_eval.communication_score = communication _apply_evaluation(evaluation, scores, comments, position)
existing_eval.gamesense_score = gamesense db.session.add(evaluation)
existing_eval.versatility_score = versatility saved += 1
existing_eval.discipline_score = discipline
existing_eval.analysis_score = analysis
existing_eval.sport_ethics_score = sport_ethics
existing_eval.mental_score = mental
existing_eval.overall_score = overall
existing_eval.comments = comments
existing_eval.position_recommendation = position
flash('Evaluation updated!', 'success')
else:
evaluation = Evaluation(
tryout_id=tryout_id, player_id=player_id,
evaluator_id=current_user.id,
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, overall_score=overall,
comments=comments, position_recommendation=position,
)
db.session.add(evaluation)
flash('Evaluation submitted successfully!', 'success')
db.session.commit() db.session.commit()
flash(f'Saved evaluations for {saved} player(s).', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
evaluators = None return render_template('pages/batch_evaluate.html',
if isinstance(current_user, Admin): tryout=tryout, players=players,
all_evaluations = Evaluation.query.filter_by( evaluation_criteria=EVALUATION_CRITERIA,
tryout_id=tryout_id, player_id=player_id,
).all()
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e}
for e in all_evaluations]
return render_template('pages/evaluate_player.html',
tryout=tryout, player=player,
existing_eval=existing_eval,
evaluators=evaluators,
game_positions=GAME_POSITIONS) game_positions=GAME_POSITIONS)
@evaluations_bp.route('/<int:tryout_id>/players')
@login_required
def players_to_evaluate(tryout_id):
"""List players that need evaluation in a specific tryout."""
if not current_user.can_evaluate():
flash('Permission denied.', 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
players = []
for reg in registrations:
p = User.query.get(reg.player_id)
if p and isinstance(p, Player):
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
).first()
players.append({'player': p, 'evaluated': existing is not None,
'registration': reg})
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
+156 -55
View File
@@ -3,16 +3,29 @@
Uses polymorphic isinstance checks instead of role-string comparisons. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from flask import Blueprint, render_template, redirect, url_for, flash from datetime import date
from flask_login import login_required, current_user
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from sqlalchemy import func
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Scout, Admin,
User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember, Coach,
Match, MatchParticipant, OrgTeam, Evaluation,
Manager,
Match,
MatchParticipant,
Player,
Scout,
TeamMember,
Tryout,
TryoutRegistration,
User,
) )
from sqlalchemy import func from app.permissions import coach_tryout_ids
from datetime import date
main_bp = Blueprint('main', __name__) main_bp = Blueprint('main', __name__)
@@ -23,6 +36,32 @@ def index():
return redirect(url_for('auth.login')) 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') @main_bp.route('/dashboard')
@login_required @login_required
def dashboard(): def dashboard():
@@ -43,46 +82,95 @@ def dashboard():
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all() 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() stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
today = date.today() today = date.today()
stats['upcoming_matches'] = Match.query.filter( stats['upcoming_matches'] = (
Match.status == 'scheduled', Match.date >= today, Match.query.filter(
).order_by(Match.date, Match.start_time).limit(5).all() Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
)
elif isinstance(user, Manager): elif isinstance(user, Manager):
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count() stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
stats['active_tryouts'] = Tryout.query.filter_by( stats['active_tryouts'] = Tryout.query.filter_by(
created_by=user.id, status='in_progress').count() created_by=user.id, status='in_progress'
).count()
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count() stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
stats['my_tryouts'] = Tryout.query.filter_by( stats['my_tryouts'] = (
created_by=user.id).order_by(Tryout.date.desc()).limit(5).all() Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
)
today = date.today() today = date.today()
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()] manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
stats['upcoming_matches'] = Match.query.filter( stats['upcoming_matches'] = (
Match.tryout_id.in_(manager_tryout_ids), Match.query.filter(
Match.status == 'scheduled', Match.date >= today, Match.tryout_id.in_(manager_tryout_ids),
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else [] 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): elif isinstance(user, Coach):
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count() stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
registrations = TryoutRegistration.query.filter(
TryoutRegistration.status.in_(['registered', 'attended'])).all() # A count, computed as a count. This used to load every registration
registered_player_ids = [r.player_id for r in registrations] # row in the club and every evaluation this coach had written, build
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()] # two Python sets and subtract them — two full table reads to produce
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids)) # one integer (PERF-004).
stats['my_recent_evaluations'] = Evaluation.query.filter_by( already_evaluated = (
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all() 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() today = date.today()
org_team = OrgTeam.query.filter_by(coach_id=user.id).first() # Was: the first team matching the legacy coach_id column, and only
coach_tryout_ids = [t.id for t in Tryout.query.filter_by( # the tryouts targeting it. A coach attached by the many-to-many
target_org_team_id=org_team.id).all()] if org_team else [] # relationship, or coaching a second team, saw no upcoming match.
stats['upcoming_matches'] = Match.query.filter( tryout_ids = coach_tryout_ids(user)
Match.tryout_id.in_(coach_tryout_ids), stats['upcoming_matches'] = (
Match.status == 'scheduled', Match.date >= today, Match.query.filter(
).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else [] 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): elif isinstance(user, Player):
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count() stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
stats['my_registrations'] = TryoutRegistration.query.filter_by( stats['my_registrations'] = (
player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all() TryoutRegistration.query.filter_by(player_id=user.id)
.order_by(TryoutRegistration.registered_at.desc())
.limit(5)
.all()
)
today = date.today() today = date.today()
next_matches = [] next_matches = []
@@ -93,10 +181,15 @@ def dashboard():
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all() player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
player_team_ids = [tm.team_id for tm in player_team_memberships] player_team_ids = [tm.team_id for tm in player_team_memberships]
upcoming_matches = Match.query.filter( upcoming_matches = (
Match.tryout_id.in_(registered_tryout_ids), Match.query.filter(
Match.status == 'scheduled', Match.date >= today, Match.tryout_id.in_(registered_tryout_ids),
).order_by(Match.date, Match.start_time).all() Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.all()
)
for match in upcoming_matches: for match in upcoming_matches:
is_participant = False is_participant = False
@@ -104,36 +197,44 @@ def dashboard():
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
if match.team1_id in player_team_ids: if match.team1_id in player_team_ids:
is_participant = True is_participant = True
team = next((tm for tm in player_team_memberships team = next(
if tm.team_id == match.team1_id), None) (tm for tm in player_team_memberships if tm.team_id == match.team1_id), None
)
elif match.team2_id in player_team_ids: elif match.team2_id in player_team_ids:
is_participant = True is_participant = True
team = next((tm for tm in player_team_memberships team = next(
if tm.team_id == match.team2_id), None) (tm for tm in player_team_memberships if tm.team_id == match.team2_id), None
)
else: else:
if match.id in player_match_ids: if match.id in player_match_ids:
is_participant = True is_participant = True
if is_participant: if is_participant:
next_matches.append({ next_matches.append(
'tryout': match.tryout, 'match': match, {
'team': team.team if team else None, 'tryout': match.tryout,
}) 'match': match,
'team': team.team if team else None,
}
)
stats['next_matches'] = next_matches stats['next_matches'] = next_matches
elif isinstance(user, Scout): elif isinstance(user, Scout):
stats['total_players'] = User.query.filter_by(role='player').count() stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count() stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = db.session.query( top_rows = (
Evaluation.player_id, db.session.query(
func.avg(Evaluation.overall_score).label('avg_score'), User,
).group_by(Evaluation.player_id).order_by( func.avg(Evaluation.overall_score).label('avg_score'),
func.avg(Evaluation.overall_score).desc()).limit(5).all() )
stats['top_players'] = [] .join(Evaluation, Evaluation.player_id == User.id)
for row in stats['avg_scores']: .filter(User.role == 'player')
p = User.query.get(row.player_id) .group_by(User.id)
if p: .order_by(func.avg(Evaluation.overall_score).desc(), User.id)
stats['top_players'].append((p, round(row.avg_score, 1))) .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) return render_template('pages/dashboard.html', user=user, stats=stats)
+451 -343
View File
@@ -3,21 +3,110 @@
Uses polymorphic isinstance checks instead of role-string comparisons. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from datetime import datetime, timedelta
from flask_login import login_required, current_user
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.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Scout, Admin,
User, Tryout, Match, MatchParticipant, Team, TeamMember, Coach,
OrgTeam, TryoutRegistration, PlayerDisponibility, Manager,
Match,
MatchParticipant,
OneOnOneRequest, OneOnOneRequest,
PersonalNote,
Player,
PlayerDisponibility,
Scout,
Team,
TeamMember,
Tryout,
TryoutRegistration,
User,
) )
from datetime import datetime, time, timedelta from app.services.scheduling import notify_participants, zip_participants
from app.discord_bot import send_schedule_notification from app.validators import MatchEditSchema, MatchSchema
matches_bp = Blueprint('matches', __name__, url_prefix='/matches') 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(): def can_schedule_match():
"""Check if user can schedule matches (Admin, Manager, Coach, Scout).""" """Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
return isinstance(current_user, (Admin, Manager, Coach, Scout)) return isinstance(current_user, (Admin, Manager, Coach, Scout))
@@ -38,17 +127,84 @@ def calendar():
return render_template('pages/calendar.html') 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') @matches_bp.route('/api/events')
@json_endpoint
@login_required @login_required
def api_events(): def api_events():
"""API endpoint returning calendar events for FullCalendar.""" """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 = [] events = []
tryouts = get_visible_tryouts_for_user() tryouts = get_visible_tryouts_for_user()
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
for tryout in tryouts: if tryouts_by_id:
for match in tryout.matches: 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' match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
match_desc = match.description or '' # '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 = '' participants_str = ''
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
teams = [] teams = []
@@ -56,102 +212,104 @@ def api_events():
teams.append(match.team1.name) teams.append(match.team1.name)
if match.team2: if match.team2:
teams.append(match.team2.name) teams.append(match.team2.name)
participants_str = f"{' vs '.join(teams)}" participants_str = ' vs '.join(teams)
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
else: else:
player_names = [] player_names = [
for p in match.participants.all(): p.player.username if p.player else 'Unknown Player'
player_names.append(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' participants_str = ', '.join(player_names) if player_names else 'No players'
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None 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 end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
user_participant = MatchParticipant.query.filter_by( user_participant = mine_by_match.get(match.id)
match_id=match.id, player_id=current_user.id,
).first()
events.append({ events.append(
'id': f'match_{match.id}', {
'title': match.title, 'id': f'match_{match.id}',
'date': match.date.strftime('%Y-%m-%d'), 'title': match.title,
'type': 'match', 'color': match_color, 'date': match.date.strftime('%Y-%m-%d'),
'extendedProps': { 'type': 'match',
'location': match.location or tryout.location or 'TBD', 'color': match_color,
'status': match.status, 'description': match_desc, 'extendedProps': {
'match_type': match.match_type, 'location': match.location or tryout.location or 'TBD',
'tryout_id': tryout.id, 'match_id': match.id, 'status': match.status,
'start_time': start_time_str, 'end_time': end_time_str, 'description': match.description or '',
'participants': participants_str, 'match_type': match.match_type,
'user_participant_id': user_participant.id if user_participant else None, 'tryout_id': tryout.id,
'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False, '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) # Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player): if isinstance(current_user, Player):
one_on_ones = OneOnOneRequest.query.filter_by( one_on_ones = OneOnOneRequest.query.filter_by(
player_id=current_user.id, player_id=current_user.id, status='approved'
status='approved'
).all() ).all()
elif isinstance(current_user, Coach): elif isinstance(current_user, Coach):
one_on_ones = OneOnOneRequest.query.filter_by( one_on_ones = OneOnOneRequest.query.filter_by(
coach_id=current_user.id, coach_id=current_user.id, status='approved'
status='approved'
).all() ).all()
else: else:
one_on_ones = [] one_on_ones = []
for ooo in one_on_ones: for ooo in one_on_ones:
event = { events.append(
'id': f'one_on_one_{ooo.id}', {
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}', 'id': f'one_on_one_{ooo.id}',
'date': ooo.date.strftime('%Y-%m-%d'), 'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'type': 'one_on_one', 'date': ooo.date.strftime('%Y-%m-%d'),
'color': '#8b5cf6', 'type': 'one_on_one',
'extendedProps': { 'color': '#8b5cf6',
'location': 'Discord / Voice Chat', 'extendedProps': {
'status': 'approved', 'location': 'Discord / Voice Chat',
'description': ooo.points or 'One on One session', 'status': 'approved',
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None, 'description': ooo.points or 'One on One session',
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None, 'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}", 'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'player_name': ooo.player.full_name, 'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
'coach_name': ooo.coach.full_name, },
}, }
} )
# Provide real start/end datetimes so FullCalendar places the event
# in the correct time slot (Week/Day views) instead of all-day.
if ooo.start_time and ooo.end_time:
start_dt = datetime.combine(ooo.date, ooo.start_time)
end_dt = datetime.combine(ooo.date, ooo.end_time)
event['start'] = start_dt.isoformat()
event['end'] = end_dt.isoformat()
events.append(event)
return jsonify(events) return jsonify(events)
@matches_bp.route('/api/events/<int:tryout_id>') @matches_bp.route('/api/events/<int:tryout_id>')
@json_endpoint
@login_required @login_required
def api_events_for_tryout(tryout_id): def api_events_for_tryout(tryout_id):
"""API endpoint returning calendar events for a specific tryout.""" """API endpoint returning calendar events for a specific tryout."""
tryout = Tryout.query.get_or_404(tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
can_view = current_user.can_manage_this_tryout(tryout) can_view = current_user.can_manage_this_tryout(tryout)
is_registered = False is_registered = False
player_in_match = False player_in_match = False
if isinstance(current_user, Player): if isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by( is_registered = (
tryout_id=tryout_id, player_id=current_user.id, TryoutRegistration.query.filter_by(
).first() is not None tryout_id=tryout_id,
player_matches = Match.query.join(MatchParticipant).filter( player_id=current_user.id,
MatchParticipant.player_id == current_user.id, ).first()
Match.tryout_id == tryout_id, is not None
).all() )
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 player_in_match = len(player_matches) > 0
if not can_view and not is_registered and not player_in_match: if not can_view and not is_registered and not player_in_match:
@@ -160,7 +318,9 @@ def api_events_for_tryout(tryout_id):
events = [] events = []
for match in tryout.matches: for match in tryout.matches:
match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b' match_color = (
'#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
)
participants_str = '' participants_str = ''
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
teams = [] teams = []
@@ -170,8 +330,16 @@ def api_events_for_tryout(tryout_id):
teams.append(match.team2.name) teams.append(match.team2.name)
participants_str = f"{' vs '.join(teams)}" participants_str = f"{' vs '.join(teams)}"
elif match.match_type == 'player_vs_player': 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] team1_players = [
team2_players = [p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player] 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: if team1_players and team2_players:
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}" participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
else: else:
@@ -183,19 +351,25 @@ def api_events_for_tryout(tryout_id):
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None 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 end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
events.append({ events.append(
'id': f'match_{match.id}', {
'title': match.title, 'id': f'match_{match.id}',
'date': match.date.strftime('%Y-%m-%d'), 'title': match.title,
'type': 'match', 'color': match_color, 'date': match.date.strftime('%Y-%m-%d'),
'extendedProps': { 'type': 'match',
'location': match.location or tryout.location or 'TBD', 'color': match_color,
'status': match.status, 'match_type': match.match_type, 'extendedProps': {
'tryout_id': tryout.id, 'match_id': match.id, 'location': match.location or tryout.location or 'TBD',
'participants': participants_str, 'status': match.status,
'start_time': start_time_str, 'end_time': end_time_str, '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) return jsonify(events)
@@ -204,288 +378,185 @@ def api_events_for_tryout(tryout_id):
@login_required @login_required
def create_match(tryout_id): def create_match(tryout_id):
"""Create a new match / scrimmage within a tryout.""" """Create a new match / scrimmage within a tryout."""
tryout = Tryout.query.get_or_404(tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to schedule matches for this tryout.', 'danger') flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.is_ended: if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be created or modified.', 'danger') 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
teams = Team.query.filter_by(tryout_id=tryout_id).all() teams = Team.query.filter_by(tryout_id=tryout_id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() all_players = registered_players(tryout_id)
all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
prefill_date = request.args.get('date', '') 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': if request.method == 'POST':
title = request.form.get('title') payload = match_form_payload()
description = request.form.get('description') # A tryout match with no date of its own happens on the tryout's day.
date_str = request.form.get('date') payload.setdefault('date', tryout.date.isoformat())
start_time_str = request.form.get('start_time')
end_time_str = request.form.get('end_time')
location = request.form.get('location')
match_type = request.form.get('match_type')
if not start_time_str:
flash('Start time is required. Please select a time slot.', 'danger')
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
all_players=all_players, prefill_date=prefill_date)
try: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date data = MatchSchema().load(payload)
except (ValueError, TypeError): except ValidationError as err:
flash('Invalid date format.', 'danger') flash_validation_errors(err)
return render_template('pages/match_form.html', tryout=tryout, teams=teams, return rerender()
all_players=all_players, prefill_date=prefill_date)
start_time = None
end_time = None
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
if end_time_str:
end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
start_dt = datetime.combine(date_obj, start_time)
end_dt = start_dt + timedelta(minutes=30)
end_time = end_dt.time()
except ValueError:
flash('Invalid time format.', 'danger')
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
match = Match( match = Match(
tryout_id=tryout_id, title=title, description=description, tryout_id=tryout_id,
date=date_obj, start_time=start_time, end_time=end_time, title=data['title'],
location=location, match_type=match_type, created_by=current_user.id, 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.add(match)
db.session.flush() db.session.flush()
notified_player_ids = [] if data['match_type'] == 'team_vs_team':
notified_participant_ids = [] match.team1_id = data['team1_id']
match.team2_id = data['team2_id']
if match_type == 'team_vs_team': notified_player_ids, notified_participant_ids = create_participants(match, data)
team1_id = request.form.get('team1_id')
team2_id = request.form.get('team2_id')
match.team1_id = int(team1_id) if team1_id else None
match.team2_id = int(team2_id) if team2_id else None
if match.team1_id:
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
if match.team2_id:
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
elif match_type == 'player_vs_player':
team1_player_ids = request.form.get('team1_player_ids', '')
team2_player_ids = request.form.get('team2_player_ids', '')
team1_ids = [int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else []
team2_ids = [int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else []
for pid in team1_ids:
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
for pid in team2_ids:
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = team1_ids + team2_ids
elif match_type == 'player_scrim':
player_ids = request.form.getlist('player_ids')
for pid in player_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = [int(p) for p in player_ids]
db.session.commit() db.session.commit()
# Discord notifications notify_participants(
event_date_str = date_obj.strftime('%Y-%m-%d') title=match.title,
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD' date=match.date,
for i, player_id in enumerate(notified_player_ids): start_time=match.start_time,
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id end_time=match.end_time,
send_schedule_notification( participants=zip_participants(notified_player_ids, notified_participant_ids),
user_id=player_id, event_type='match', event_title=match.title, fallback_id=match.id,
event_date=event_date_str, event_time=event_time_str, )
reference_id=reference_id,
)
flash('Match scheduled successfully!', 'success') flash(_('Match scheduled successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template('pages/match_form.html', tryout=tryout, teams=teams, return rerender()
all_players=all_players, prefill_date=prefill_date)
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST']) @matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit_match(match_id): def edit_match(match_id):
"""Edit an existing match.""" """Edit an existing match."""
match = Match.query.get_or_404(match_id) match = db.get_or_404(Match, match_id)
tryout = match.tryout tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to edit this match.', 'danger') flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('matches.calendar')) return redirect(url_for('matches.calendar'))
if tryout.is_ended: if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be created or modified.', 'danger') 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
teams = Team.query.filter_by(tryout_id=tryout.id).all() teams = Team.query.filter_by(tryout_id=tryout.id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all() all_players = registered_players(tryout.id)
all_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
current_player_ids = [p.player_id for p in match.participants.all()] 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()] 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()] 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': if request.method == 'POST':
match.title = request.form.get('title')
match.description = request.form.get('description')
date_str = request.form.get('date')
start_time_str = request.form.get('start_time')
end_time_str = request.form.get('end_time')
location = request.form.get('location')
status = request.form.get('status')
try: try:
match.date = datetime.strptime(date_str, '%Y-%m-%d').date() data = MatchEditSchema().load(match_form_payload())
except (ValueError, TypeError): except ValidationError as err:
flash('Invalid date format.', 'danger') flash_validation_errors(err)
return render_template('pages/match_form.html', match=match, tryout=tryout, return rerender()
teams=teams, all_players=all_players,
current_player_ids=current_player_ids)
if not start_time_str: # Assigned only once the whole form has been accepted. Assigning as
flash('Start time is required.', 'danger') # each field was read meant a form rejected halfway had already
return render_template('pages/match_form.html', match=match, tryout=tryout, # changed the record in the session.
teams=teams, all_players=all_players, match.title = data['title']
current_player_ids=current_player_ids) match.description = data['description']
match.date = data['date']
try: match.start_time = data['start_time']
match.start_time = datetime.strptime(start_time_str, '%H:%M').time() match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
if end_time_str: match.location = data['location']
match.end_time = datetime.strptime(end_time_str, '%H:%M').time() match.status = data['status']
else:
start_dt = datetime.combine(match.date, match.start_time)
end_dt = start_dt + timedelta(minutes=30)
match.end_time = end_dt.time()
except ValueError:
match.start_time = None
match.location = location
if status in ['scheduled', 'completed', 'cancelled']:
match.status = status
notified_player_ids = [] notified_player_ids = []
notified_participant_ids = [] notified_participant_ids = []
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
team1_id = request.form.get('team1_id') teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
team2_id = request.form.get('team2_id') if teams_changed:
new_team1_id = int(team1_id) if team1_id else None
new_team2_id = int(team2_id) if team2_id else None
if new_team1_id != match.team1_id or new_team2_id != match.team2_id:
MatchParticipant.query.filter_by(match_id=match.id).delete() MatchParticipant.query.filter_by(match_id=match.id).delete()
match.team1_id = new_team1_id match.team1_id = data['team1_id']
match.team2_id = new_team2_id match.team2_id = data['team2_id']
if match.team1_id: notified_player_ids, notified_participant_ids = create_participants(match, data)
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
if match.team2_id:
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
else: else:
if match.team1_id: # Same teams: the roster stands, but everyone is told again,
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()]) # because the date or the time may have moved.
if match.team2_id: for team_id in (match.team1_id, match.team2_id):
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all()]) if team_id:
elif match.match_type == 'player_vs_player': 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() MatchParticipant.query.filter_by(match_id=match.id).delete()
team1_str = request.form.get('team1_player_ids', '') notified_player_ids, notified_participant_ids = create_participants(match, data)
team2_str = request.form.get('team2_player_ids', '')
t1_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else []
t2_ids = [p for p in team2_str.split(',') if p.strip()] if team2_str else []
for pid in t1_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
for pid in t2_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = [int(p) for p in t1_ids] + [int(p) for p in t2_ids]
elif match.match_type == 'player_scrim':
MatchParticipant.query.filter_by(match_id=match.id).delete()
player_ids = request.form.getlist('player_ids')
for pid in player_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = [int(p) for p in player_ids]
db.session.commit() db.session.commit()
# Discord notifications notify_participants(
end_time_val = match.end_time or (match.start_time if match.start_time else None) title=match.title,
if match.start_time and end_time_val: date=match.date,
event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}" start_time=match.start_time,
else: end_time=match.end_time,
event_time_str = 'TBD' participants=zip_participants(notified_player_ids, notified_participant_ids),
event_date_str = match.date.strftime('%Y-%m-%d') fallback_id=match.id,
for i, player_id in enumerate(notified_player_ids): )
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
send_schedule_notification(
user_id=player_id, event_type='match', event_title=match.title,
event_date=event_date_str, event_time=event_time_str,
reference_id=reference_id,
)
flash('Match updated successfully!', 'success') flash(_('Match updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
participants_map = {} return rerender()
for p in match.participants.all():
participants_map[p.player_id] = {
'participant_id': p.id,
'attendance_confirmed': p.attendance_confirmed,
'team_side': p.team_side,
}
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)
@matches_bp.route('/api/manageable-tryouts') @matches_bp.route('/api/manageable-tryouts')
@json_endpoint
@login_required @login_required
def api_manageable_tryouts(): def api_manageable_tryouts():
"""API endpoint returning tryouts the current user can manage.""" """API endpoint returning tryouts the current user can manage."""
@@ -496,11 +567,14 @@ def api_manageable_tryouts():
manageable = [] manageable = []
for t in tryouts: for t in tryouts:
if current_user.can_manage_this_tryout(t): if current_user.can_manage_this_tryout(t):
manageable.append({ manageable.append(
'id': t.id, 'title': t.title, {
'date': t.date.strftime('%Y-%m-%d'), 'id': t.id,
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None, '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) return jsonify(manageable)
@@ -508,48 +582,79 @@ def api_manageable_tryouts():
@login_required @login_required
def delete_match(match_id): def delete_match(match_id):
"""Delete a match.""" """Delete a match."""
match = Match.query.get_or_404(match_id) match = db.get_or_404(Match, match_id)
tryout = match.tryout tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to delete this match.', 'danger') flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('matches.calendar')) return redirect(url_for('matches.calendar'))
if tryout.is_ended: if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be deleted.', 'danger') flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) 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.delete(match)
db.session.commit() db.session.commit()
flash('Match deleted successfully.', 'success') flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
def get_players_available_at_time(date_str, time_str): def get_players_available_at_time(date_str, time_str):
"""Get list of player IDs available at a specific date and time.""" """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: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() parsed_date = datetime.strptime(date_str, '%Y-%m-%d')
time_obj = datetime.strptime(time_str, '%H:%M').time() time_obj = datetime.strptime(time_str, '%H:%M').time()
except (ValueError, TypeError): except (ValueError, TypeError):
return [] return []
date_for_day = datetime.strptime(date_str, '%Y-%m-%d') day_of_week = parsed_date.weekday()
day_of_week = date_for_day.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 []
players = User.query.filter_by(role='player', is_active_account=True).all() # The comparison stays in Python: start_time and end_time are stored as
available_players = [] # time columns, and comparing them in SQL across three backends is not
for player in players: # worth the portability risk for a single day's rows.
disponibilities = PlayerDisponibility.query.filter_by( minutes = time_obj.hour * 60 + time_obj.minute
player_id=player.id, day_of_week=day_of_week, available = []
).all() seen = set()
for disp in disponibilities: for disp in PlayerDisponibility.query.filter_by(day_of_week=day_of_week).all():
disp_start = disp.start_time.hour * 60 + disp.start_time.minute if disp.player_id in seen or disp.player_id not in active_player_ids:
disp_end = disp.end_time.hour * 60 + disp.end_time.minute continue
match_time = time_obj.hour * 60 + time_obj.minute start = disp.start_time.hour * 60 + disp.start_time.minute
if disp_start <= match_time < disp_end: end = disp.end_time.hour * 60 + disp.end_time.minute
available_players.append(player.id) if start <= minutes < end:
break available.append(disp.player_id)
return available_players seen.add(disp.player_id)
return available
@matches_bp.route('/api/available_players/<date>/<time>') @matches_bp.route('/api/available_players/<date>/<time>')
@json_endpoint
@login_required @login_required
def api_available_players(date, time): def api_available_players(date, time):
"""API endpoint to get players available at a specific date/time slot.""" """API endpoint to get players available at a specific date/time slot."""
@@ -560,13 +665,14 @@ def api_available_players(date, time):
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST']) @matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@json_endpoint
@login_required @login_required
def toggle_presence(match_id, participant_id): def toggle_presence(match_id, participant_id):
"""Toggle attendance_confirmed for a match participant.""" """Toggle attendance_confirmed for a match participant."""
match = Match.query.get_or_404(match_id) match = db.get_or_404(Match, match_id)
tryout = match.tryout tryout = match.tryout
participant = MatchParticipant.query.get_or_404(participant_id) participant = db.get_or_404(MatchParticipant, participant_id)
if participant.match_id != match_id: if participant.match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400 return jsonify({'error': 'Participant does not belong to this match'}), 400
@@ -576,8 +682,10 @@ def toggle_presence(match_id, participant_id):
participant.attendance_confirmed = not participant.attendance_confirmed participant.attendance_confirmed = not participant.attendance_confirmed
db.session.commit() db.session.commit()
return jsonify({ return jsonify(
'participant_id': participant.id, {
'attendance_confirmed': participant.attendance_confirmed, 'participant_id': participant.id,
'player_name': participant.player.username if participant.player else 'Unknown', 'attendance_confirmed': participant.attendance_confirmed,
}) 'player_name': participant.player.username if participant.player else 'Unknown',
}
)
+164 -170
View File
@@ -3,32 +3,42 @@
Uses polymorphic isinstance checks instead of role-string comparisons. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_login import login_required, current_user 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.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Admin,
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer, Coach,
Manager,
OrgTeam,
Player,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
AppSettings, AppSettings,
) )
from datetime import datetime, timedelta from app.pagination import paginate
from app.discord_bot import send_schedule_notification 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') team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
def can_manage_team_match(team): def can_manage_team_match(team):
"""Check if current user can manage matches for this team.""" """Whether the current user can manage matches for this team.
if isinstance(current_user, Admin):
return True Same rule as administering the team itself, so it is the same call.
if isinstance(current_user, Manager): This function used to restate it, and the restatement drifted.
return True """
if isinstance(current_user, Coach): return can_manage_org_team(current_user, team)
if team.coaches.filter_by(id=current_user.id).first():
return True
if team.coach_id == current_user.id:
return True
return False
def season_locked(): def season_locked():
@@ -48,29 +58,21 @@ def list_matches():
"""List all team matches visible to the current user.""" """List all team matches visible to the current user."""
filter_team_id = request.args.get('team_id', type=int) filter_team_id = request.args.get('team_id', type=int)
if isinstance(current_user, Admin): # 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() teams = OrgTeam.query.order_by(OrgTeam.name).all()
matches_query = TeamMatch.query matches_query = TeamMatch.query
elif isinstance(current_user, Manager): elif isinstance(current_user, (Coach, Player)):
teams = OrgTeam.query.order_by(OrgTeam.name).all() teams = visible_org_teams(current_user)
matches_query = TeamMatch.query
elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id,
)
).order_by(OrgTeam.name).all()
team_ids = [t.id for t in teams] team_ids = [t.id for t in teams]
matches_query = TeamMatch.query.filter( matches_query = (
TeamMatch.org_team_id.in_(team_ids), TeamMatch.query.filter(
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1) TeamMatch.org_team_id.in_(team_ids),
elif isinstance(current_user, Player): )
player_team_ids = [tp.org_team_id for tp in current_user.team_placements] if team_ids
teams = OrgTeam.query.filter(OrgTeam.id.in_(player_team_ids)).all() if player_team_ids else [] else TeamMatch.query.filter(TeamMatch.id == -1)
matches_query = TeamMatch.query.filter( )
TeamMatch.org_team_id.in_(player_team_ids),
) if player_team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
else: else:
teams = [] teams = []
matches_query = TeamMatch.query.filter(TeamMatch.id == -1) matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
@@ -78,46 +80,57 @@ def list_matches():
if filter_team_id: if filter_team_id:
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id) matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
matches = matches_query.order_by(TeamMatch.date.desc()).all() # 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 = [] match_data = []
for tm in matches: for tm in matches:
confirmed, total = tm.get_confirmed_count() confirmed, total = tm.get_confirmed_count()
participants = [] participants = []
for p in tm.participants.all(): for p in tm.participants.all():
participants.append({ participants.append(
'id': p.id, 'player': p.player, {
'is_confirmed': p.is_confirmed, 'id': p.id,
}) 'player': p.player,
match_data.append({ 'is_confirmed': p.is_confirmed,
'match': tm, 'participants': participants, }
'confirmed_count': confirmed, 'total_count': total, )
}) match_data.append(
{
'match': tm,
'participants': participants,
'confirmed_count': confirmed,
'total_count': total,
}
)
return render_template('pages/team_matches.html', return render_template(
teams=teams, match_data=match_data, 'pages/team_matches.html',
now=datetime.utcnow()) teams=teams,
match_data=match_data,
pagination=matches_page,
now=utc_now_naive(),
)
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST']) @team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
@login_required @login_required
def create_match(team_id): def create_match(team_id):
"""Create a new regular-season team match.""" """Create a new regular-season team match."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not can_manage_team_match(team): if not can_manage_team_match(team):
flash('You do not have permission to schedule matches for this team.', 'danger') flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
if season_locked(): team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
flash('The regular season is not active. An admin must begin a season before scheduling matches.', 'danger')
return redirect(url_for('team_matches.list_matches'))
team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()]
prefill_date = request.args.get('date', '') prefill_date = request.args.get('date', '')
is_practice = request.args.get('type') == 'practice' is_practice = request.args.get('type') == 'practice'
default_title = 'Practice' if is_practice else f'Team Match — {team.name}' default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
if is_practice and request.method == 'GET': if is_practice and request.method == 'GET':
class TryoutProxy: class TryoutProxy:
def __init__(self, team_obj): def __init__(self, team_obj):
self.id = 0 self.id = 0
@@ -129,56 +142,49 @@ def create_match(team_id):
proxy_tryout = TryoutProxy(team) proxy_tryout = TryoutProxy(team)
all_players = [tp.player for tp in team_players if tp.player] all_players = [tp.player for tp in team_players if tp.player]
return render_template('pages/match_form.html', return render_template(
tryout=proxy_tryout, teams=[], all_players=all_players, 'pages/match_form.html',
prefill_date=prefill_date, is_practice=True, tryout=proxy_tryout,
team_id=team_id, team=team) teams=[],
all_players=all_players,
prefill_date=prefill_date,
is_practice=True,
team_id=team_id,
team=team,
)
if request.method == 'POST': if request.method == 'POST':
title = request.form.get('title', default_title) payload = form_payload(list_fields=(), optional_blank=())
opponent = request.form.get('opponent', '').strip() if not is_practice else None # A practice has no opponent, whatever the form sent.
description = request.form.get('description', '') payload.setdefault('title', default_title)
date_str = request.form.get('date') if is_practice:
start_time_str = request.form.get('start_time') payload.pop('opponent', None)
end_time_str = request.form.get('end_time')
location = request.form.get('location', '')
if not date_str:
flash('Date is required.', 'danger')
return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date)
try: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() data = TeamMatchSchema().load(payload)
except (ValueError, TypeError): except ValidationError as err:
flash('Invalid date format.', 'danger') flash_validation_errors(err)
return render_template('pages/team_match_form.html', team=team, return render_template(
team_players=team_players, prefill_date=prefill_date, 'pages/team_match_form.html',
is_practice=is_practice) team=team,
team_players=team_players,
prefill_date=prefill_date,
is_practice=is_practice,
)
start_time = None start_time = data['start_time']
end_time = None end_time = data['end_time'] or default_end_time(data['date'], start_time)
if start_time_str:
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
if end_time_str:
end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
start_dt = datetime.combine(date_obj, start_time)
end_dt = start_dt + timedelta(minutes=30)
end_time = end_dt.time()
except ValueError:
flash('Invalid time format.', 'danger')
return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date,
is_practice=is_practice)
team_match = TeamMatch( team_match = TeamMatch(
org_team_id=team_id, title=title, org_team_id=team_id,
description=description or None, title=data['title'],
opponent=opponent or None, description=data['description'],
date=date_obj, start_time=start_time, end_time=end_time, opponent=data['opponent'],
location=location or None, created_by=current_user.id, 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.add(team_match)
db.session.flush() db.session.flush()
@@ -186,7 +192,8 @@ def create_match(team_id):
notified_participant_ids = [] notified_participant_ids = []
for tp in team_players: for tp in team_players:
participant = TeamMatchParticipant( participant = TeamMatchParticipant(
team_match_id=team_match.id, player_id=tp.player_id, team_match_id=team_match.id,
player_id=tp.player_id,
) )
db.session.add(participant) db.session.add(participant)
db.session.flush() db.session.flush()
@@ -194,20 +201,20 @@ def create_match(team_id):
db.session.commit() db.session.commit()
# Discord notifications notify_participants(
event_date_str = date_obj.strftime('%Y-%m-%d') title=team_match.title,
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD' 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,
)
for i, tp in enumerate(team_players): flash(
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id _('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
send_schedule_notification( )
user_id=tp.player_id, event_type='match',
event_title=team_match.title,
event_date=event_date_str, event_time=event_time_str,
reference_id=reference_id,
)
flash(f'Team match "{title}" scheduled successfully!', 'success')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', team=team, team_players=team_players) return render_template('pages/team_match_form.html', team=team, team_players=team_players)
@@ -217,76 +224,65 @@ def create_match(team_id):
@login_required @login_required
def edit_match(match_id): def edit_match(match_id):
"""Edit an existing team match.""" """Edit an existing team match."""
team_match = TeamMatch.query.get_or_404(match_id) team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team team = team_match.org_team
if not can_manage_team_match(team): if not can_manage_team_match(team):
flash('You do not have permission to edit this match.', 'danger') flash(_('You do not have permission to edit this match.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
if season_locked():
flash('The regular season is not active. An admin must begin a season before editing matches.', 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
if request.method == 'POST': if request.method == 'POST':
team_match.title = request.form.get('title', team_match.title) # The three date and time fields used to be checked one at a time,
team_match.description = request.form.get('description', '') or None # each flashing and redirecting on its own: a form with two mistakes
team_match.opponent = request.form.get('opponent', '').strip() or None # 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=[]
)
date_str = request.form.get('date') team_match.title = data['title']
if date_str: team_match.description = data['description']
try: team_match.opponent = data['opponent']
team_match.date = datetime.strptime(date_str, '%Y-%m-%d').date() team_match.date = data['date']
except (ValueError, TypeError): team_match.start_time = data['start_time']
flash('Invalid date format.', 'danger') team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
return redirect(url_for('team_matches.edit_match', match_id=match_id)) team_match.location = data['location']
team_match.status = data['status']
start_time_str = request.form.get('start_time')
if start_time_str:
try:
team_match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
except ValueError:
pass
end_time_str = request.form.get('end_time')
if end_time_str:
try:
team_match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
except ValueError:
pass
team_match.location = request.form.get('location', '') or None
status = request.form.get('status')
if status in ['scheduled', 'completed', 'cancelled']:
team_match.status = status
db.session.commit() db.session.commit()
flash('Match updated successfully!', 'success') flash(_('Match updated successfully!'), 'success')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', return render_template(
match=team_match, team=team, team_players=[]) 'pages/team_match_form.html', match=team_match, team=team, team_players=[]
)
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST']) @team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
@login_required @login_required
def delete_match(match_id): def delete_match(match_id):
"""Delete a team match.""" """Delete a team match."""
team_match = TeamMatch.query.get_or_404(match_id) team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team team = team_match.org_team
if not can_manage_team_match(team): if not can_manage_team_match(team):
flash('You do not have permission to delete this match.', 'danger') flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
if season_locked():
flash('The regular season is not active. An admin must begin a season before deleting matches.', 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
db.session.delete(team_match) db.session.delete(team_match)
db.session.commit() db.session.commit()
flash('Match deleted successfully.', 'success') flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
@team_matches_bp.route('/api/manageable-teams') @team_matches_bp.route('/api/manageable-teams')
@json_endpoint
@login_required @login_required
def api_manageable_teams(): def api_manageable_teams():
"""API endpoint returning teams the current user can schedule matches for.""" """API endpoint returning teams the current user can schedule matches for."""
@@ -296,12 +292,7 @@ def api_manageable_teams():
if isinstance(current_user, (Admin, Manager)): if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all() teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, Coach): elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter( teams = coach_org_teams(current_user)
db.or_(
OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id,
)
).order_by(OrgTeam.name).all()
else: else:
return jsonify([]) return jsonify([])
@@ -309,13 +300,14 @@ def api_manageable_teams():
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST']) @team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@json_endpoint
@login_required @login_required
def toggle_presence(match_id, participant_id): def toggle_presence(match_id, participant_id):
"""Toggle is_confirmed for a team match participant.""" """Toggle is_confirmed for a team match participant."""
team_match = TeamMatch.query.get_or_404(match_id) team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team team = team_match.org_team
participant = TeamMatchParticipant.query.get_or_404(participant_id) participant = db.get_or_404(TeamMatchParticipant, participant_id)
if participant.team_match_id != match_id: if participant.team_match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400 return jsonify({'error': 'Participant does not belong to this match'}), 400
@@ -325,8 +317,10 @@ def toggle_presence(match_id, participant_id):
participant.is_confirmed = not participant.is_confirmed participant.is_confirmed = not participant.is_confirmed
db.session.commit() db.session.commit()
return jsonify({ return jsonify(
'participant_id': participant.id, {
'is_confirmed': participant.is_confirmed, 'participant_id': participant.id,
'player_name': participant.player.username if participant.player else 'Unknown', 'is_confirmed': participant.is_confirmed,
}) 'player_name': participant.player.username if participant.player else 'Unknown',
}
)
+354 -190
View File
@@ -3,15 +3,32 @@
Uses polymorphic isinstance checks instead of role-string comparisons. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
from flask_login import login_required, current_user 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.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Admin,
OrgTeam, User, Team, TeamMember, Coach,
PersonalNote, TeamNote, Tryout, TeamPlayer, Contract,
Manager,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Player,
TeamMatch,
TeamNote,
TeamPlayer,
Tryout,
User,
) )
from datetime import datetime 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 = Blueprint('teams', __name__, url_prefix='/teams')
@@ -22,34 +39,35 @@ def list_teams():
"""List all organization teams visible to the current user.""" """List all organization teams visible to the current user."""
can_manage = current_user.can_manage_teams() can_manage = current_user.can_manage_teams()
if isinstance(current_user, Admin): if isinstance(current_user, Player):
teams = OrgTeam.query.order_by(OrgTeam.name).all() flash(_('Use My Team(s) to view your teams.'), 'info')
elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id,
)
).order_by(OrgTeam.name).all()
elif isinstance(current_user, Manager):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.managers.any(id=current_user.id),
OrgTeam.manager_id == current_user.id,
)
).order_by(OrgTeam.name).all()
elif isinstance(current_user, Player):
flash('Use My Team(s) to view your teams.', 'info')
return redirect(url_for('teams.my_teams')) return redirect(url_for('teams.my_teams'))
else: if not isinstance(current_user, (Admin, Coach, Manager)):
flash('You do not have permission to view teams.', 'danger') flash(_('You do not have permission to view teams.'), 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all() teams = visible_org_teams(current_user)
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
all_players = User.query.filter_by(role='player').order_by(User.username).all() coaches = (
return render_template('pages/teams.html', teams=teams, coaches=coaches, User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
managers=managers, all_players=all_players, can_manage=can_manage) )
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') @teams_bp.route('/my-teams')
@@ -57,84 +75,146 @@ def list_teams():
def my_teams(): def my_teams():
"""View the player's own teams with upcoming matches.""" """View the player's own teams with upcoming matches."""
if not isinstance(current_user, Player): if not isinstance(current_user, Player):
flash('This page is for players.', 'info') flash(_('This page is for players.'), 'info')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
from app.models import TeamMatch, TeamMatchParticipant from app.models import TeamMatch, TeamMatchParticipant
player_teams = current_user.get_org_teams() player_teams = current_user.get_org_teams()
now = datetime.utcnow() now = utc_now_naive()
team_data = [] team_data = []
for org_team in player_teams: for org_team in player_teams:
matches = TeamMatch.query.filter( matches = (
TeamMatch.org_team_id == org_team.id, TeamMatch.query.filter(
TeamMatch.status == 'scheduled', TeamMatch.org_team_id == org_team.id,
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all() TeamMatch.status == 'scheduled',
)
.order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc())
.all()
)
matches_data = [] matches_data = []
for tm in matches: for tm in matches:
confirmed, total = tm.get_confirmed_count() confirmed, total = tm.get_confirmed_count()
participant = TeamMatchParticipant.query.filter_by( participant = TeamMatchParticipant.query.filter_by(
team_match_id=tm.id, player_id=current_user.id, team_match_id=tm.id,
player_id=current_user.id,
).first() ).first()
matches_data.append({ matches_data.append(
'match': tm, {
'participant_id': participant.id if participant else None, 'match': tm,
'is_confirmed': participant.is_confirmed if participant else False, 'participant_id': participant.id if participant else None,
'confirmed_count': confirmed, 'total_count': total, 'is_confirmed': participant.is_confirmed if participant else False,
}) 'confirmed_count': confirmed,
'total_count': total,
}
)
team_data.append({ team_data.append(
'team': org_team, 'matches': matches_data, {
'coaches': org_team.get_coaches(), 'team': org_team,
'managers': org_team.get_managers(), '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) 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']) @teams_bp.route('/create', methods=['POST'])
@login_required @login_required
def create_team(): def create_team():
"""Create a new organization team.""" """Create a new organization team."""
if not current_user.can_manage_teams(): if not current_user.can_manage_teams():
flash('You do not have permission to create teams.', 'danger') flash(_('You do not have permission to create teams.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
name = request.form.get('name') try:
coach_id = request.form.get('coach_id') data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
manager_id = request.form.get('manager_id') except ValidationError as err:
flash_validation_errors(err)
if not name:
flash('Team name is required.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
existing = OrgTeam.query.filter_by(name=name).first() name = data['name']
if existing: if OrgTeam.query.filter_by(name=name).first():
flash(f'Team "{name}" already exists.', 'danger') flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
coach = _staff_member(data['coach_id'], Coach)
manager = _staff_member(data['manager_id'], Manager)
team = OrgTeam( team = OrgTeam(
name=name, name=name,
coach_id=int(coach_id) if coach_id else None, coach_id=coach.id if coach else None,
manager_id=int(manager_id) if manager_id else None, manager_id=manager.id if manager else None,
created_by=current_user.id, created_by=current_user.id,
) )
db.session.add(team) db.session.add(team)
db.session.flush() db.session.flush()
if coach_id: if coach:
coach_user = User.query.get(int(coach_id)) team.coaches.append(coach)
if coach_user: if manager:
team.coaches.append(coach_user) team.managers.append(manager)
if manager_id:
manager_user = User.query.get(int(manager_id))
if manager_user:
team.managers.append(manager_user)
db.session.commit() db.session.commit()
flash(f'Team "{name}" created successfully!', 'success') flash(_('Team "%(name)s" created successfully!', name=name), 'success')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -142,85 +222,112 @@ def create_team():
@login_required @login_required
def edit_team(team_id): def edit_team(team_id):
"""Edit an existing organization team.""" """Edit an existing organization team."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to edit this team.', 'danger') flash(_('You do not have permission to edit this team.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
name = request.form.get('name') try:
coach_id = request.form.get('coach_id') data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids')))
manager_id = request.form.get('manager_id') except ValidationError as err:
flash_validation_errors(err)
if not name:
flash('Team name is required.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
existing = OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first() name = data['name']
if existing: if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first():
flash(f'Team "{name}" already exists.', 'danger') flash(_('Team "%(name)s" already exists.', name=name), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
if request.form.get('sync_staff') == '1': team.name = name
coach_ids = request.form.getlist('coach_ids')
manager_ids = request.form.getlist('manager_ids')
team.coaches = [] if data['sync_staff'] == '1':
for cid in coach_ids: team.coaches = [
if cid and cid.strip(): user for user in (_staff_member(cid, Coach) for cid in data['coach_ids']) if user
coach_user = User.query.get(int(cid)) ]
if coach_user and isinstance(coach_user, Coach):
team.coaches.append(coach_user)
coach_list = team.coaches.all() coach_list = team.coaches.all()
team.coach_id = coach_list[0].id if coach_list else None team.coach_id = coach_list[0].id if coach_list else None
team.managers = [] team.managers = [
for mid in manager_ids: user for user in (_staff_member(mid, Manager) for mid in data['manager_ids']) if user
if mid and mid.strip(): ]
manager_user = User.query.get(int(mid))
if manager_user and isinstance(manager_user, Manager):
team.managers.append(manager_user)
manager_list = team.managers.all() manager_list = team.managers.all()
team.manager_id = manager_list[0].id if manager_list else None team.manager_id = manager_list[0].id if manager_list else None
else: else:
team.coach_id = int(coach_id) if coach_id else None # This branch never checked the role, while the one above did — the
team.manager_id = int(manager_id) if manager_id else None # 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)
if coach_id: team.coach_id = coach.id if coach else None
coach_user = User.query.get(int(coach_id)) team.manager_id = manager.id if manager else None
if coach_user and not team.coaches.filter_by(id=coach_user.id).first():
team.coaches.append(coach_user) if coach and not team.coaches.filter_by(id=coach.id).first():
if manager_id: team.coaches.append(coach)
manager_user = User.query.get(int(manager_id)) if manager and not team.managers.filter_by(id=manager.id).first():
if manager_user and not team.managers.filter_by(id=manager_user.id).first(): team.managers.append(manager)
team.managers.append(manager_user)
db.session.commit() db.session.commit()
flash(f'Team "{name}" updated successfully!', 'success') flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/delete', methods=['POST']) @teams_bp.route('/<int:team_id>/delete', methods=['POST'])
@login_required @login_required
def delete_team(team_id): def delete_team(team_id):
"""Delete an organization team.""" """Delete an organization team.
if not current_user.can_manage_teams():
flash('You do not have permission to delete teams.', 'danger') 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')) return redirect(url_for('teams.list_teams'))
team = OrgTeam.query.get_or_404(team_id)
name = team.name name = team.name
tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all() # One transaction. This used to commit three times, so a failure at the
for t in tryouts: # third step left the tryouts detached and the players removed without
t.target_org_team_id = None # the team being deleted — an inconsistent state nothing could undo.
db.session.commit() #
# 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.
TeamPlayer.query.filter_by(org_team_id=team_id).delete() # Entities that only make sense as part of the team.
db.session.commit() 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.delete(team)
db.session.commit() db.session.commit()
flash(f'Team "{name}" deleted successfully.', 'success') flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -228,30 +335,42 @@ def delete_team(team_id):
@login_required @login_required
def add_coach(team_id): def add_coach(team_id):
"""Add a coach to an organization team.""" """Add a coach to an organization team."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
coach_id = request.form.get('coach_id') data = _posted(TeamStaffSchema())
if not coach_id: if data is None:
flash('Please select a coach.', 'danger') 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')) return redirect(url_for('teams.list_teams'))
coach = User.query.get_or_404(int(coach_id)) coach = db.session.get(User, data['coach_id'])
if not isinstance(coach, Coach): if not coach or not _assignable(coach, Coach):
flash('Only coaches can be assigned as coach.', 'danger') flash(_('Only coaches can be assigned as coach.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
if team.coaches.filter_by(id=coach.id).first(): if team.coaches.filter_by(id=coach.id).first():
flash(f'{coach.username} is already a coach of {team.name}.', 'info') flash(
_(
'%(username)s is already a coach of %(name)s.',
username=coach.username,
name=team.name,
),
'info',
)
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
team.coaches.append(coach) team.coaches.append(coach)
if not team.coach_id: if not team.coach_id:
team.coach_id = coach.id team.coach_id = coach.id
db.session.commit() db.session.commit()
flash(f'{coach.username} added as coach of {team.name}.', 'success') flash(
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -259,30 +378,42 @@ def add_coach(team_id):
@login_required @login_required
def add_manager(team_id): def add_manager(team_id):
"""Add a manager to an organization team.""" """Add a manager to an organization team."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
manager_id = request.form.get('manager_id') data = _posted(TeamStaffSchema())
if not manager_id: if data is None:
flash('Please select a manager.', 'danger') 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')) return redirect(url_for('teams.list_teams'))
manager = User.query.get_or_404(int(manager_id)) manager = db.session.get(User, data['manager_id'])
if not isinstance(manager, Manager): if not manager or not _assignable(manager, Manager):
flash('Only managers can be assigned as manager.', 'danger') flash(_('Only managers can be assigned as manager.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
if team.managers.filter_by(id=manager.id).first(): if team.managers.filter_by(id=manager.id).first():
flash(f'{manager.username} is already a manager of {team.name}.', 'info') flash(
_(
'%(username)s is already a manager of %(name)s.',
username=manager.username,
name=team.name,
),
'info',
)
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
team.managers.append(manager) team.managers.append(manager)
if not team.manager_id: if not team.manager_id:
team.manager_id = manager.id team.manager_id = manager.id
db.session.commit() db.session.commit()
flash(f'{manager.username} added as manager of {team.name}.', 'success') flash(
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -290,14 +421,17 @@ def add_manager(team_id):
@login_required @login_required
def remove_coach(team_id): def remove_coach(team_id):
"""Remove a coach from an organization team.""" """Remove a coach from an organization team."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
coach_id = request.form.get('coach_id') data = _posted(TeamStaffSchema())
if coach_id: if data is None:
coach = User.query.get(int(coach_id)) 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(): if coach and team.coaches.filter_by(id=coach.id).first():
team.coaches.remove(coach) team.coaches.remove(coach)
if team.coach_id == coach.id: if team.coach_id == coach.id:
@@ -307,7 +441,7 @@ def remove_coach(team_id):
team.coach_id = None team.coach_id = None
db.session.commit() db.session.commit()
flash(f'Coach removed from {team.name}.', 'success') flash(_('Coach removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -315,14 +449,17 @@ def remove_coach(team_id):
@login_required @login_required
def remove_manager(team_id): def remove_manager(team_id):
"""Remove a manager from an organization team.""" """Remove a manager from an organization team."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
manager_id = request.form.get('manager_id') data = _posted(TeamStaffSchema())
if manager_id: if data is None:
manager = User.query.get(int(manager_id)) 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(): if manager and team.managers.filter_by(id=manager.id).first():
team.managers.remove(manager) team.managers.remove(manager)
if team.manager_id == manager.id: if team.manager_id == manager.id:
@@ -332,7 +469,7 @@ def remove_manager(team_id):
team.manager_id = None team.manager_id = None
db.session.commit() db.session.commit()
flash(f'Manager removed from {team.name}.', 'success') flash(_('Manager removed from %(name)s.', name=team.name), 'success')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -340,31 +477,36 @@ def remove_manager(team_id):
@login_required @login_required
def add_player(team_id): def add_player(team_id):
"""Add a player to an organization team.""" """Add a player to an organization team."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
player_id = request.form.get('player_id') data = _posted(TeamPlayerSchema())
status = request.form.get('status', 'starter') if data is None:
if not player_id: return redirect(url_for('teams.list_teams'))
flash('Please select a player.', 'danger') if not data['player_id']:
flash(_('Please select a player.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(int(player_id)) status = data['status']
if not isinstance(player, Player): player = db.session.get(User, data['player_id'])
flash('Can only assign players to teams.', 'danger') if not player or not _assignable(player, Player):
flash(_('Can only assign players to teams.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first() existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
if existing: if existing:
flash(f'{player.username} is already on {team.name}.', 'info') flash(
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
'info',
)
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status) tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
db.session.add(tp) db.session.add(tp)
db.session.commit() db.session.commit()
flash(f'{player.username} added to {team.name}!', 'success') flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -372,28 +514,35 @@ def add_player(team_id):
@login_required @login_required
def remove_player(team_id, player_id): def remove_player(team_id, player_id):
"""Remove a player from an organization team.""" """Remove a player from an organization team."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id) player = db.get_or_404(User, player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp: if not tp:
flash(f'{player.username} is not on {team.name}.', 'danger') flash(
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger',
)
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
db.session.delete(tp) db.session.delete(tp)
db.session.commit() db.session.commit()
flash(f'{player.username} removed from {team.name}.', 'success') flash(
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST']) @teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
@json_endpoint
@login_required @login_required
def toggle_player_status(team_id, player_id): def toggle_player_status(team_id, player_id):
"""Toggle a player's status between starter and substitute.""" """Toggle a player's status between starter and substitute."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
return jsonify({'error': 'Permission denied'}), 403 return jsonify({'error': 'Permission denied'}), 403
@@ -403,27 +552,35 @@ def toggle_player_status(team_id, player_id):
tp.status = 'substitute' if tp.status == 'starter' else 'starter' tp.status = 'substitute' if tp.status == 'starter' else 'starter'
db.session.commit() db.session.commit()
return jsonify({ return jsonify(
'success': True, 'player_id': player_id, {
'new_status': tp.status, 'player_name': tp.player.username, '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']) @teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
@login_required @login_required
def add_team_note(team_id): def add_team_note(team_id):
"""Add a team improvement note (coaches only).""" """Add a team improvement note (coaches only)."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to add notes to this team.', 'danger') flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip() try:
if content: data = NoteContentSchema().load(form_payload(list_fields=()))
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content) except ValidationError as err:
db.session.add(note) flash_validation_errors(err)
db.session.commit() return redirect(url_for('teams.list_teams'))
flash('Team notes added successfully!', 'success')
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')) return redirect(url_for('teams.list_teams'))
@@ -431,25 +588,32 @@ def add_team_note(team_id):
@login_required @login_required
def add_player_note(team_id, player_id): def add_player_note(team_id, player_id):
"""Add a personal note for a player (coaches only).""" """Add a personal note for a player (coaches only)."""
team = OrgTeam.query.get_or_404(team_id) team = db.get_or_404(OrgTeam, team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to add notes to this team.', 'danger') flash(_('You do not have permission to add notes to this team.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id) player = db.get_or_404(User, player_id)
if not isinstance(player, Player): if not isinstance(player, Player):
flash('Can only add notes for players.', 'danger') flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp: if not tp:
flash(f'{player.username} is not on {team.name}.', 'danger') flash(
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger',
)
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip() try:
if content: data = NoteContentSchema().load(form_payload(list_fields=()))
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content) except ValidationError as err:
db.session.add(note) flash_validation_errors(err)
db.session.commit() return redirect(url_for('teams.list_teams'))
flash(f'Note added for {player.username}!', 'success')
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')) return redirect(url_for('teams.list_teams'))
+423 -275
View File
@@ -4,17 +4,42 @@ This module handles CRUD operations for tryouts and player registrations.
Uses polymorphic isinstance checks instead of role-string comparisons. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from flask import Blueprint, render_template, redirect, url_for, flash, request from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask_login import login_required, current_user 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.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Scout, ESPORT_GAMES,
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember, GAME_POSITIONS,
OrgTeam, Match, MatchParticipant, Admin,
ESPORT_GAMES, GAME_POSITIONS, Coach,
AppSettings, 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,
) )
from datetime import datetime
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts') tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
@@ -24,15 +49,67 @@ def can_manage():
return isinstance(current_user, (Admin, Manager)) return isinstance(current_user, (Admin, Manager))
def tryouts_locked(): def tryout_form_payload():
"""Return True when tryouts are globally closed to coaches/managers. """The tryout form, shaped for marshmallow (ARCH-005)."""
return form_payload(list_fields=('coach_ids', 'manager_ids'), optional_blank=())
Admins are always allowed to bypass the lock. Coaches and managers can
only make changes when the global tryout switch is open. 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 isinstance(current_user, Admin): if not coach_ids:
return False return []
return not AppSettings.get_bool('tryouts_open', default=True) return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all()
def managers_from_ids(manager_ids):
"""The manager accounts behind these ids, filtered by role."""
if not manager_ids:
return []
return User.query.filter(User.id.in_(manager_ids), User.role == 'manager').all()
def _users_by_id(user_ids):
"""Load these users in one query, keyed by id.
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('') @tryouts_bp.route('')
@@ -43,7 +120,7 @@ def list_tryouts():
Delegates to the polymorphic User subclass's get_visible_tryouts() method. Delegates to the polymorphic User subclass's get_visible_tryouts() method.
""" """
tryouts = current_user.get_visible_tryouts() tryouts = current_user.get_visible_tryouts()
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow()) return render_template('pages/tryouts.html', tryouts=tryouts, now=utc_now_naive())
@tryouts_bp.route('/create', methods=['GET', 'POST']) @tryouts_bp.route('/create', methods=['GET', 'POST'])
@@ -51,222 +128,232 @@ def list_tryouts():
def create_tryout(): def create_tryout():
"""Create a new tryout event. Requires Admin or Manager.""" """Create a new tryout event. Requires Admin or Manager."""
if not can_manage(): if not can_manage():
flash('You do not have permission to create tryouts.', 'danger') flash(_('You do not have permission to create tryouts.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all() 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() managers = (
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all() 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': if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
game = request.form.get('game')
date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location')
max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id')
coach_ids = request.form.getlist('coach_ids')
try: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() data = TryoutSchema().load(tryout_form_payload())
except (ValueError, TypeError): except ValidationError as err:
flash('Invalid start date format.', 'danger') flash_validation_errors(err)
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams, return rerender()
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
end_date_obj = None
if end_date_str:
try:
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash('End date cannot be before start date.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
except (ValueError, TypeError):
flash('Invalid end date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
tryout = Tryout( tryout = Tryout(
title=title, description=description, game=game, date=date_obj, title=data['title'],
end_date=end_date_obj, description=data['description'],
location=location, game=data['game'],
max_players=int(max_players) if max_players else None, date=data['date'],
created_by=current_user.id, status='upcoming', end_date=data['end_date'],
target_org_team_id=int(target_org_team_id) if target_org_team_id else None, location=data['location'],
manager_id=int(manager_id) if manager_id else None, max_players=data['max_players'],
created_by=current_user.id,
status='upcoming',
target_org_team_id=data['target_org_team_id'],
) )
db.session.add(tryout) db.session.add(tryout)
db.session.flush() db.session.flush()
# Assign coaches via many-to-many tryout.coaches = coaches_from_ids(data['coach_ids'])
if coach_ids: tryout.managers = managers_from_ids(data['manager_ids'])
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
db.session.commit() db.session.commit()
flash('Tryout created successfully!', 'success') flash(_('Tryout created successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams, return rerender()
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST']) @tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit_tryout(tryout_id): def edit_tryout(tryout_id):
"""Edit an existing tryout event. Permission based on can_manage_this_tryout.""" """Edit an existing tryout event. Permission based on can_manage_this_tryout."""
tryout = Tryout.query.get_or_404(tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to edit this tryout.', 'danger') flash(_('You do not have permission to edit this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
if tryout.is_ended: if tryout.is_ended:
flash('This tryout has ended and can no longer be modified.', 'danger') flash(_('This tryout has ended and can no longer be modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all() 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() managers = (
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all() 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': if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
game = request.form.get('game')
date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location')
max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id')
coach_ids = request.form.getlist('coach_ids')
try: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() data = TryoutSchema().load(tryout_form_payload())
except (ValueError, TypeError): except ValidationError as err:
flash('Invalid start date format.', 'danger') flash_validation_errors(err)
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, return rerender()
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
end_date_obj = None tryout.title = data['title']
if end_date_str: tryout.description = data['description']
try: tryout.game = data['game']
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date() tryout.date = data['date']
if end_date_obj < date_obj: tryout.end_date = data['end_date']
flash('End date cannot be before start date.', 'danger') tryout.location = data['location']
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, tryout.max_players = data['max_players']
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) tryout.target_org_team_id = data['target_org_team_id']
except (ValueError, TypeError):
flash('Invalid end date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
tryout.title = title # Only update staff lists when the form explicitly sends them.
tryout.description = description # An absent checkbox group (all unchecked or JS failed) means
tryout.game = game # \"don't change\", not \"remove everyone\".
tryout.date = date_obj if 'coach_ids' in request.form:
tryout.end_date = end_date_obj tryout.coaches = coaches_from_ids(data['coach_ids'])
tryout.location = location if 'manager_ids' in request.form:
tryout.max_players = int(max_players) if max_players else None tryout.managers = managers_from_ids(data['manager_ids'])
tryout.target_org_team_id = int(target_org_team_id) if target_org_team_id else None
tryout.manager_id = int(manager_id) if manager_id else None
# Update coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
else:
tryout.coaches = []
db.session.commit() db.session.commit()
flash('Tryout updated successfully!', 'success') flash(_('Tryout updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, return rerender()
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
@tryouts_bp.route('/<int:tryout_id>') @tryouts_bp.route('/<int:tryout_id>')
@login_required @login_required
def view_tryout(tryout_id): def view_tryout(tryout_id):
"""View a specific tryout with all details. Permission via polymorphic dispatch.""" """View a specific tryout with all details. Permission via polymorphic dispatch."""
tryout = Tryout.query.get_or_404(tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
can_view = False can_view = False
if isinstance(current_user, Admin): if isinstance(current_user, Admin):
can_view = True can_view = True
elif isinstance(current_user, Manager): elif isinstance(current_user, Manager):
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Coach): elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout) can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player): elif isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by( is_registered = (
tryout_id=tryout_id, player_id=current_user.id).first() is not None TryoutRegistration.query.filter_by(
player_in_match = MatchParticipant.query.join(Match).filter( tryout_id=tryout_id, player_id=current_user.id
MatchParticipant.player_id == current_user.id, ).first()
Match.tryout_id == tryout_id, is not None
).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 can_view = is_registered or player_in_match
elif isinstance(current_user, Scout): elif isinstance(current_user, Scout):
can_view = True can_view = True
if not can_view: if not can_view:
flash('You do not have permission to view this tryout.', 'danger') flash(_('You do not have permission to view this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) 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() registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
registered_players = [User.query.get(r.player_id) for r in registrations if r.player_id] 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() evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all()
player_eval_status = {} player_eval_status = {}
if current_user.can_evaluate(): if current_user.can_evaluate():
for p in registered_players: evaluated_by_me = {
existing = Evaluation.query.filter_by( row.player_id
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id, for row in evaluations
).first() if row.evaluator_id == current_user.id and row.player_id
player_eval_status[p.id] = existing is not None }
player_eval_status = {p.id: p.id in evaluated_by_me for p in registered_players}
is_registered = TryoutRegistration.query.filter_by( is_registered = (
tryout_id=tryout_id, player_id=current_user.id, TryoutRegistration.query.filter_by(
).first() is not None tryout_id=tryout_id,
player_id=current_user.id,
).first()
is not None
)
teams = Team.query.filter_by(tryout_id=tryout_id).all() teams = Team.query.filter_by(tryout_id=tryout_id).all()
team_data = [] team_ids = [team.id for team in teams]
for team in teams: members_by_team = {}
members = TeamMember.query.filter_by(team_id=team.id).all() if team_ids:
team_data.append({ member_rows = TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).all()
'team': team, member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
'members': [{'player': User.query.get(m.player_id), 'position': m.position} for row in member_rows:
for m in members], 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_edit = current_user.can_manage_this_tryout(tryout)
can_view_calendar = can_edit can_view_calendar = can_edit
if isinstance(current_user, Player): if isinstance(current_user, Player):
player_in_match = MatchParticipant.query.join(Match).filter( player_in_match = (
MatchParticipant.player_id == current_user.id, MatchParticipant.query.join(Match)
Match.tryout_id == tryout_id, .filter(
).first() is not None MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.first()
is not None
)
can_view_calendar = is_registered or player_in_match can_view_calendar = is_registered or player_in_match
all_players = None all_players = None
if can_edit: if can_edit:
all_players = User.query.filter_by(role='player').order_by(User.username).all() # 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() matches = (
Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
)
match_data = [] match_data = []
for match in matches: for match in matches:
all_participants = list(match.participants.all()) all_participants = list(match.participants.all())
@@ -276,78 +363,114 @@ def view_tryout(tryout_id):
player_presence = [] player_presence = []
for p in all_participants: for p in all_participants:
if p.player: if p.player:
player_presence.append({ player_presence.append(
'participant_id': p.id, 'player_id': p.player_id, {
'player_name': p.player.username, 'participant_id': p.id,
'attendance_confirmed': p.attendance_confirmed, 'player_id': p.player_id,
}) 'player_name': p.player.username,
'attendance_confirmed': p.attendance_confirmed,
}
)
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
participants = { participants = {
'team1': match.team1.name if match.team1 else 'TBD', 'team1': match.team1.name if match.team1 else 'TBD',
'team2': match.team2.name if match.team2 else 'TBD', 'team2': match.team2.name if match.team2 else 'TBD',
'team1_players': [{'name': m.player.username, 'position': m.position} 'team1_players': [
for m in match.team1.members.all()] if match.team1 else [], {'name': m.player.username, 'position': m.position}
'team2_players': [{'name': m.player.username, 'position': m.position} for m in match.team1.members.all()
for m in match.team2.members.all()] if match.team2 else [], ]
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': elif match.match_type == 'player_vs_player':
team1_players = [{'name': p.player.username, 'position': p.position} # Filtered from the list already in hand. Asking the dynamic
for p in match.participants.filter_by(team_side=1).all() if p.player] # relationship again cost two more round trips per match for
team2_players = [{'name': p.player.username, 'position': p.position} # rows that were loaded a dozen lines above.
for p in match.participants.filter_by(team_side=2).all() if p.player] 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 = { participants = {
'team1': 'Team 1', 'team2': 'Team 2', 'team1': 'Team 1',
'team1_players': team1_players, 'team2_players': team2_players, 'team2': 'Team 2',
'team1_players': team1_players,
'team2_players': team2_players,
} }
else: else:
participants = [p.player.username for p in match.participants.all()] participants = [p.player.username for p in match.participants.all()]
match_data.append({ match_data.append(
'match': match, 'participants': participants, {
'confirmed_count': confirmed_count, 'total_count': total_count, 'match': match,
'player_presence': player_presence, 'participants': participants,
}) 'confirmed_count': confirmed_count,
'total_count': total_count,
'player_presence': player_presence,
}
)
return render_template('pages/view_tryout.html', return render_template(
tryout=tryout, registered_players=registered_players, 'pages/view_tryout.html',
evaluations=evaluations, player_eval_status=player_eval_status, tryout=tryout,
is_registered=is_registered, registrations=registrations, registered_players=registered_players,
team_data=team_data, can_edit=can_edit, evaluations=evaluations,
can_view_calendar=can_view_calendar, all_players=all_players, player_eval_status=player_eval_status,
matches=matches, match_data=match_data, is_registered=is_registered,
game_positions=GAME_POSITIONS, now=datetime.utcnow()) 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']) @tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
@login_required @login_required
def register_for_tryout(tryout_id): def register_for_tryout(tryout_id):
"""Register a player for a tryout. Only Players can self-register.""" """Register a player for a tryout. Only Players can self-register."""
tryout = Tryout.query.get_or_404(tryout_id)
if not isinstance(current_user, Player): if not isinstance(current_user, Player):
flash('Only players can register for tryouts.', 'danger') flash(_('Only players can register for tryouts.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 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']: if tryout.status not in ['upcoming', 'in_progress']:
flash('This tryout is not accepting registrations.', 'danger') flash(_('This tryout is not accepting registrations.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by( existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id).first() tryout_id=tryout_id, player_id=current_user.id
).first()
if existing: if existing:
flash('You are already registered for this tryout.', 'info') flash(_('You are already registered for this tryout.'), 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players: if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players: if count >= tryout.max_players:
flash('This tryout is full.', 'danger') flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id) registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
db.session.add(registration) db.session.add(registration)
db.session.commit() db.session.commit()
flash('Successfully registered for tryout!', 'success') flash(_('Successfully registered for tryout!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -355,18 +478,19 @@ def register_for_tryout(tryout_id):
@login_required @login_required
def update_status(tryout_id): def update_status(tryout_id):
"""Update the status of a tryout.""" """Update the status of a tryout."""
tryout = Tryout.query.get_or_404(tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked(): try:
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
new_status = request.form.get('status')
if new_status in ['upcoming', 'in_progress', 'completed']: tryout.status = data['status']
tryout.status = new_status db.session.commit()
db.session.commit() flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
flash(f'Tryout status updated to {new_status}.', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -374,22 +498,23 @@ def update_status(tryout_id):
@login_required @login_required
def update_registration_status(tryout_id, player_id): def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status.""" """Update a registration's attendance status."""
tryout = Tryout.query.get_or_404(tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked(): registration = TryoutRegistration.query.filter_by(
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration.query.filter_by( registration.status = data['status']
tryout_id=tryout_id, player_id=player_id).first_or_404() db.session.commit()
new_status = request.form.get('status') flash(_('Registration status updated.'), 'success')
if new_status in ['registered', 'attended', 'no_show']:
registration.status = new_status
db.session.commit()
flash('Registration status updated.', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -397,39 +522,46 @@ def update_registration_status(tryout_id, player_id):
@login_required @login_required
def register_player(tryout_id): def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches).""" """Manually register a player for a tryout (by managers/coaches)."""
tryout = Tryout.query.get_or_404(tryout_id) tryout = locked_tryout_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryouts_locked(): try:
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') data = PlayerSelectionSchema().load(form_payload())
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) except ValidationError as err:
player_id = request.form.get('player_id') flash_validation_errors(err)
if not player_id:
flash('Please select a player.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(int(player_id)) if not data['player_id']:
if not isinstance(player, Player): flash(_('Please select a player.'), 'danger')
flash('Can only register players.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by( # Same two checks as the team roster (SEC-16): the right role, and an
tryout_id=tryout_id, player_id=player.id).first() # 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: if existing:
flash(f'{player.username} is already registered for this tryout.', 'info') flash(
_('%(username)s is already registered for this tryout.', username=player.username),
'info',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players: if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players: if count >= tryout.max_players:
flash('This tryout is full.', 'danger') flash(_('This tryout is full.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id) registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
db.session.add(registration) db.session.add(registration)
db.session.commit() db.session.commit()
flash(f'{player.username} registered for tryout!', 'success') flash(_('%(username)s registered for tryout!', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -437,19 +569,16 @@ def register_player(tryout_id):
@login_required @login_required
def remove_player(tryout_id, player_id): def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches).""" """Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = Tryout.query.get_or_404(tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked(): player = db.get_or_404(User, player_id)
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(player_id)
registration = TryoutRegistration.query.filter_by( registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first() tryout_id=tryout_id, player_id=player_id
).first()
if registration: if registration:
db.session.delete(registration) db.session.delete(registration)
@@ -468,7 +597,7 @@ def remove_player(tryout_id, player_id):
).delete(synchronize_session=False) ).delete(synchronize_session=False)
db.session.commit() db.session.commit()
flash(f'{player.username} removed from tryout.', 'success') flash(_('%(username)s removed from tryout.', username=player.username), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -476,21 +605,21 @@ def remove_player(tryout_id, player_id):
@login_required @login_required
def create_team(tryout_id): def create_team(tryout_id):
"""Create a tryout-specific team.""" """Create a tryout-specific team."""
tryout = Tryout.query.get_or_404(tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryouts_locked(): try:
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team_name = request.form.get('team_name') team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
if team_name: db.session.add(team)
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id) db.session.commit()
db.session.add(team) flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
db.session.commit()
flash(f'Team "{team_name}" created!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -498,26 +627,42 @@ def create_team(tryout_id):
@login_required @login_required
def add_to_team(tryout_id, team_id): def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team.""" """Add a player to a tryout team."""
team = Team.query.get_or_404(team_id) team = db.get_or_404(Team, team_id)
tryout = Tryout.query.get_or_404(tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryouts_locked(): # The two ids arrive independently in the URL. Without this check, being
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') # 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = request.form.get('player_id')
position = request.form.get('position', '')
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing: if existing:
flash('Player is already on this team.', 'info') flash(_('Player is already on this team.'), 'info')
else: else:
member = TeamMember(team_id=team_id, player_id=int(player_id), position=position) member = TeamMember(team_id=team_id, player_id=player_id, position=data['position'])
db.session.add(member) db.session.add(member)
db.session.commit() db.session.commit()
flash('Player added to team!', 'success') flash(_('Player added to team!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -525,40 +670,43 @@ def add_to_team(tryout_id, team_id):
@login_required @login_required
def delete_tryout(tryout_id): def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations).""" """Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = Tryout.query.get_or_404(tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to delete this tryout.', 'danger') flash(_('You do not have permission to delete this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# Delete match participants for all matches in this tryout
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()] 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: if match_ids:
MatchParticipant.query.filter( PersonalNote.query.filter(PersonalNote.match_id.in_(match_ids)).update(
MatchParticipant.match_id.in_(match_ids) {'match_id': None}, synchronize_session=False
).delete(synchronize_session=False) )
# Delete matches 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) Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
# Delete team members for all teams in this tryout
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
if team_ids: if team_ids:
TeamMember.query.filter( TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).delete(synchronize_session=False)
TeamMember.team_id.in_(team_ids)
).delete(synchronize_session=False)
# Delete teams
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False) Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
# Delete registrations
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete() TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
# Delete evaluations
Evaluation.query.filter_by(tryout_id=tryout_id).delete() Evaluation.query.filter_by(tryout_id=tryout_id).delete()
db.session.delete(tryout) db.session.delete(tryout)
db.session.commit() db.session.commit()
flash('Tryout deleted successfully.', 'success') flash(_('Tryout deleted successfully.'), 'success')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
+22 -17
View File
@@ -408,9 +408,18 @@ def add_disponibility():
@users_bp.route('/disponibilities/add_bulk', methods=['POST']) @users_bp.route('/disponibilities/add_bulk', methods=['POST'])
@login_required @login_required
def add_disponibilities_bulk(): def add_disponibilities_bulk():
"""Add multiple disponibility blocks at once.""" """Replace the player's disponibilities with the submitted slots.
Performs a full replace (delete existing + insert submitted) so that
deselected slots are correctly removed, mirroring the coach availability
flow. This keeps the auto-save idempotent and correct.
"""
data = request.get_json() data = request.get_json()
slots = data.get('slots', []) slots = data.get('slots', []) if data else []
# Clear existing disponibilities for the current player.
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
created = [] created = []
for slot in slots: for slot in slots:
day_of_week = slot.get('day_of_week') day_of_week = slot.get('day_of_week')
@@ -423,21 +432,17 @@ def add_disponibilities_bulk():
continue continue
end_time = add_30_minutes(start_time) end_time = add_30_minutes(start_time)
existing = PlayerDisponibility.query.filter_by( disponibility = PlayerDisponibility(
player_id=current_user.id, day_of_week=day_of_week, start_time=start_time, player_id=current_user.id, day_of_week=day_of_week,
).first() start_time=start_time, end_time=end_time,
if not existing: )
disponibility = PlayerDisponibility( db.session.add(disponibility)
player_id=current_user.id, day_of_week=day_of_week, db.session.flush()
start_time=start_time, end_time=end_time, created.append({
) 'id': disponibility.id, 'day_of_week': disponibility.day_of_week,
db.session.add(disponibility) 'day_name': DAY_NAMES[disponibility.day_of_week],
db.session.flush() 'start_time': disponibility.start_time.strftime('%H:%M'),
created.append({ })
'id': disponibility.id, 'day_of_week': disponibility.day_of_week,
'day_name': DAY_NAMES[disponibility.day_of_week],
'start_time': disponibility.start_time.strftime('%H:%M'),
})
db.session.commit() db.session.commit()
return jsonify({'success': True, 'created': created}) return jsonify({'success': True, 'created': created})
+36
View File
@@ -0,0 +1,36 @@
"""User-facing routes, split by subject.
Was a single 1 699-line module covering account administration, profiles,
availability calendars, contracts, one-on-one sessions and coach notes —
six subjects that shared nothing but a URL prefix (ARCH-004).
Importing this package registers every route on `users_bp`, so app.py
keeps its single `from app.routes.users import users_bp`. The blueprint
itself lives in blueprint.py to keep that import one-directional.
"""
# Imported for their side effect: each module attaches its routes to
# users_bp. Order does not matter; none of them import each other.
from app.routes.users import (
accounts, # noqa: F401,E402
availability, # noqa: F401,E402
contracts, # noqa: F401,E402
notes, # noqa: F401,E402
one_on_one, # noqa: F401,E402
profile, # noqa: F401,E402
)
# Re-exported because tests and other modules reach for them by name.
from app.routes.users._shared import ( # noqa: F401,E402
ALLOWED_CONTRACT_EXTENSIONS,
ALLOWED_SIGNED_EXTENSIONS,
pdf_upload_error,
)
from app.routes.users.blueprint import users_bp
__all__ = [
'ALLOWED_CONTRACT_EXTENSIONS',
'ALLOWED_SIGNED_EXTENSIONS',
'pdf_upload_error',
'users_bp',
]
+89
View File
@@ -0,0 +1,89 @@
"""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
@@ -0,0 +1,383 @@
"""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
@@ -0,0 +1,270 @@
"""When people are free.
Two calendars that share a shape without sharing a purpose: a player's
weekly availability blocks, and a coach's bookable slots for one-on-one
sessions.
"""
from flask import flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.api import json_endpoint
from app.extensions import db
from app.forms import form_payload
from app.models import Coach, CoachAvailability, PlayerDisponibility, User
from app.routes.users.blueprint import users_bp
from app.timeslots import day_name, slot_end
from app.validators import TimeSlotSchema
def _load_slots(payload_slots):
"""Validate a batch of posted slots, keeping the rejects.
Both bulk endpoints used to `continue` past anything malformed and then
answer `{'success': True}`. The client had no way to learn that a slot
had been dropped — and for coach availability that is destructive, since
the route deletes every existing slot before re-adding the ones it
accepted. A payload the browser mangled could therefore wipe a coach's
bookable hours and report success (MNT-12).
Args:
payload_slots: Whatever arrived under the `slots` key.
Returns:
tuple[list[dict], list[str]]: Accepted slots, and one message per
rejected one.
"""
schema = TimeSlotSchema()
accepted, rejected = [], []
for index, raw in enumerate(payload_slots or []):
if not isinstance(raw, dict):
rejected.append(f'slot {index}: expected an object')
continue
try:
accepted.append(schema.load(raw))
except ValidationError as err:
details = '; '.join(
f'{field}: {" ".join(str(m) for m in messages)}'
for field, messages in err.messages.items()
)
rejected.append(f'slot {index}: {details}')
return accepted, rejected
@users_bp.route('/disponibilities')
@json_endpoint
@login_required
def get_disponibilities():
"""API endpoint to get all player disponibilities for scheduling."""
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
return jsonify({'error': 'Unauthorized'}), 403
players = (
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
)
result = {}
for player in players:
disponibilities = list(player.disponibilities)
result[player.id] = {
'username': player.username,
'disponibilities': [
{
'id': d.id,
'day_of_week': d.day_of_week,
'day_name': day_name(d.day_of_week),
'start_time': d.start_time.strftime('%H:%M'),
'end_time': d.end_time.strftime('%H:%M'),
}
for d in disponibilities
],
}
return jsonify(result)
@users_bp.route('/disponibilities/my')
@json_endpoint
@login_required
def get_my_disponibilities():
"""API endpoint for players to get their own disponibilities."""
disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all()
result = {}
for d in disponibilities:
day = d.day_of_week
if day not in result:
result[day] = []
result[day].append(
{
'id': d.id,
'day_of_week': d.day_of_week,
'day_name': day_name(d.day_of_week),
'start_time': d.start_time.strftime('%H:%M'),
'end_time': d.end_time.strftime('%H:%M'),
}
)
return jsonify(result)
@users_bp.route('/disponibilities/add', methods=['POST'])
@json_endpoint
@login_required
def add_disponibility():
"""Add a disponibility block for the current player."""
try:
slot = TimeSlotSchema().load(form_payload())
except ValidationError as err:
return jsonify({'error': 'Invalid slot', 'details': err.messages}), 400
day_of_week = slot['day_of_week']
start_time = slot['start_time']
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=day_of_week,
start_time=start_time,
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.commit()
return jsonify(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
'end_time': disponibility.end_time.strftime('%H:%M'),
}
)
@users_bp.route('/disponibilities/add_bulk', methods=['POST'])
@json_endpoint
@login_required
def add_disponibilities_bulk():
"""Replace the current player's disponibility blocks atomically."""
data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots'))
if rejected:
return jsonify(
{
'error': 'Invalid slots; nothing was changed.',
'rejected': rejected,
}
), 400
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
created = []
for slot in accepted:
start_time = slot['start_time']
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.flush()
created.append(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
}
)
db.session.commit()
return jsonify({'success': True, 'created': created, 'rejected': []})
@users_bp.route('/disponibilities/clear', methods=['POST'])
@json_endpoint
@login_required
def clear_disponibilities():
"""Clear all disponibilities for the current player."""
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
db.session.commit()
return jsonify({'success': True})
@users_bp.route('/disponibilities/<int:disponibility_id>/delete', methods=['POST'])
@json_endpoint
@login_required
def delete_disponibility(disponibility_id):
"""Delete a disponibility block."""
disponibility = 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
@@ -0,0 +1,16 @@
"""The `users` blueprint object, on its own.
Every route module in this package imports it from here rather than from
the package __init__, so there is no import cycle to reason about and no
ordering constraint between the modules.
The blueprint stays a *single* blueprint even though the package holds six
route modules. Splitting it into `users_accounts`, `users_contracts` and so
on would rename 137 endpoints, and every one of them is spelled out in a
`url_for('users.…')` somewhere in the templates. The goal of ARCH-004 is a
file you can read, not a URL map you have to relearn.
"""
from flask import Blueprint
users_bp = Blueprint('users', __name__, url_prefix='/users')
+211
View File
@@ -0,0 +1,211 @@
"""Player contracts: upload, sign, download."""
import os
import uuid
from 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
@@ -0,0 +1,437 @@
"""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
@@ -0,0 +1,267 @@
"""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
@@ -0,0 +1,140 @@
"""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
@@ -0,0 +1 @@
"""Business services: work that is neither a route nor a model."""
+127
View File
@@ -0,0 +1,127 @@
"""Outbound notifications.
Extracted from app/routes/users.py, where it sat between two route
definitions and pulled `requests`, `logging` and the Discord bot into a
module whose subject is HTTP handlers (ARCH-003).
Failures here are swallowed and logged on purpose: a notification that does
not reach Discord must not roll back the session it was announcing. That is
a property of the caller — a Flask request whose work is already committed —
not of the failure, which is why the breadth is argued for at each of the
two boundaries below rather than assumed (ARCH-008 / QUA-004).
The webhook branch is narrower than it was: `requests.RequestException`
covers every way an HTTP call can fail, and anything else coming out of it
is a defect worth seeing.
"""
import logging
import os
import requests
logger = logging.getLogger(__name__)
#: Either a webhook URL, or a bare Discord user id to DM instead.
DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL', '')
def send_discord_notification(
player_name,
points,
date_str,
start_time_str,
end_time_str,
team_name,
coach_name,
coach_discord,
coach_discord_id,
request_id=None,
):
"""Send a Discord notification for a One on One request.
Args:
player_name: Who is asking.
points: Free-text discussion points, possibly empty.
date_str, start_time_str, end_time_str: Already formatted for display.
team_name: The player's team, or None.
coach_name: Who is being asked.
coach_discord: The coach's Discord handle, for the webhook footer.
coach_discord_id: The coach's Discord snowflake, for a direct message.
request_id: OneOnOneRequest primary key, so reactions can find it back.
"""
if coach_discord_id:
try:
from app.discord_bot import send_one_on_one_dm
send_one_on_one_dm(
coach_name=coach_name,
coach_discord_id=coach_discord_id,
player_name=player_name,
team_name=team_name,
date_str=date_str,
start_time=start_time_str,
end_time=end_time_str,
points=points,
request_id=request_id,
)
except Exception: # noqa: BLE001 — the request that booked the meeting is already committed
logger.warning('Failed to hand the One on One DM to the bot', exc_info=True)
if not DISCORD_WEBHOOK_URL:
return
# A bare snowflake here means "DM this person instead", and only when the
# coach has no id of their own. Queueing it cannot raise (see _enqueue).
if DISCORD_WEBHOOK_URL.isdigit():
if not coach_discord_id:
from app.discord_bot import send_one_on_one_dm
send_one_on_one_dm(
coach_name=coach_name,
coach_discord_id=DISCORD_WEBHOOK_URL,
player_name=player_name,
team_name=team_name,
date_str=date_str,
start_time=start_time_str,
end_time=end_time_str,
points=points,
)
return
embed = {
"embeds": [
{
"title": "One on One Request",
"color": 3447003,
"fields": [
{"name": "Player", "value": player_name, "inline": True},
{
"name": "Team",
"value": team_name or "Unknown Team",
"inline": True,
},
{"name": "Date", "value": date_str, "inline": True},
{
"name": "Time",
"value": f"{start_time_str} - {end_time_str}",
"inline": True,
},
{
"name": "Discussion Points",
"value": points or "No specific points provided",
"inline": False,
},
],
"footer": {
"text": f"Coach: {coach_name}"
+ (f" (Discord: {coach_discord})" if coach_discord else ""),
},
}
],
}
try:
requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5)
except requests.RequestException as exc:
logger.warning('Failed to post the One on One webhook: %s', exc)
+96
View File
@@ -0,0 +1,96 @@
"""Announcing a scheduled match to the people who have to be there.
The same twenty lines appeared three times — matches.create_match,
matches.edit_match and team_matches.create_match — each formatting the date
and time itself, then walking two parallel lists in lockstep to pair a
player with the participant row that a Discord reaction has to find again
(ARCH-003).
Three copies meant three chances to drift, and they had:
create_match read the times from local variables it had just parsed, while
edit_match re-derived them from the saved row and substituted the start
time for a missing end time. The rule kept here is the more careful of the
two.
"""
import logging
from app.discord_bot import send_schedule_notification
logger = logging.getLogger(__name__)
#: What the bot shows when a match has no usable time.
TIME_UNKNOWN = 'TBD'
def format_event_time(start_time, end_time):
"""Render a match's time range the way the Discord message expects.
Args:
start_time: A time, or None.
end_time: A time, or None. Falls back to start_time, so a match with
only a start still announces something useful.
Returns:
str: 'HH:MM AM - HH:MM PM', or TIME_UNKNOWN.
"""
if not start_time:
return TIME_UNKNOWN
finish = end_time or start_time
return f'{start_time.strftime("%I:%M %p")} - {finish.strftime("%I:%M %p")}'
def zip_participants(player_ids, participant_ids):
"""Pair each player with the participant row that was created for them.
The routes build these as two parallel lists, appended in step. Pairing
them by index is what the original code did, and it is only correct as
long as they stay in step — hence one place to look at rather than
three. A short participant list yields None, which
:func:`notify_participants` turns into the fallback reference.
Args:
player_ids: Player primary keys, in creation order.
participant_ids: Participant row ids, in the same order.
Yields:
tuple[int, int | None]: (player_id, participant_id).
"""
for index, player_id in enumerate(player_ids):
yield player_id, participant_ids[index] if index < len(participant_ids) else None
def notify_participants(*, title, date, start_time, end_time, participants, fallback_id):
"""Tell each participant that a match has been scheduled or changed.
Args:
title: Match title, shown in the message.
date: The match date.
start_time: Start time, or None.
end_time: End time, or None.
participants: Iterable of (player_id, participant_id) pairs.
participant_id is what a Discord reaction resolves back to, so
attendance lands on the right row.
fallback_id: Reference to use when a participant row has no id —
the match's own, which the bot can still act on.
Returns:
int: How many notifications were handed to the bot.
"""
event_date = date.strftime('%Y-%m-%d')
event_time = format_event_time(start_time, end_time)
sent = 0
for player_id, participant_id in participants:
if not player_id:
continue
send_schedule_notification(
user_id=player_id,
event_type='match',
event_title=title,
event_date=event_date,
event_time=event_time,
reference_id=participant_id or fallback_id,
)
sent += 1
return sent
+119 -3
View File
@@ -96,6 +96,7 @@ a:hover { color: var(--primary-dark); }
} }
.logo i { color: var(--primary); font-size: 1.6rem; } .logo i { color: var(--primary); font-size: 1.6rem; }
.logo-img { height: 40px; width: auto; }
.user-badge { .user-badge {
display: flex; display: flex;
@@ -138,7 +139,10 @@ a:hover { color: var(--primary-dark); }
padding: 10px 0; padding: 10px 0;
} }
.nav-links li a { /* Logging out is a POST, so its nav entry is a button inside a form
rather than a link. It has to read as one of the entries above it. */
.nav-links li a,
.nav-links li .nav-form button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -148,7 +152,17 @@ a:hover { color: var(--primary-dark); }
font-size: 0.9rem; font-size: 0.9rem;
} }
.nav-links li a:hover, .nav-links li a.active { .nav-links li .nav-form button {
width: 100%;
background: none;
border: 0;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.nav-links li a:hover, .nav-links li a.active,
.nav-links li .nav-form button:hover {
background: rgba(255,255,255,0.08); background: rgba(255,255,255,0.08);
color: white; color: white;
} }
@@ -158,7 +172,8 @@ a:hover { color: var(--primary-dark); }
padding-left: 17px; padding-left: 17px;
} }
.nav-links li a i { width: 20px; text-align: center; font-size: 1.1rem; } .nav-links li a i,
.nav-links li .nav-form button i { width: 20px; text-align: center; font-size: 1.1rem; }
.nav-divider { .nav-divider {
height: 1px; height: 1px;
@@ -607,6 +622,12 @@ a:hover { color: var(--primary-dark); }
margin-bottom: 12px; margin-bottom: 12px;
} }
.auth-logo-img {
height: 80px;
width: auto;
margin-bottom: 12px;
}
.auth-header h2 { .auth-header h2 {
font-size: 1.6rem; font-size: 1.6rem;
font-weight: 700; font-weight: 700;
@@ -1948,3 +1969,98 @@ a:hover { color: var(--primary-dark); }
[data-theme="dark"] .error-container p { [data-theme="dark"] .error-container p {
color: var(--text-secondary); color: var(--text-secondary);
} }
/* =========================================================================
Language switcher
========================================================================= */
.nav-language {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.nav-language .lang-link {
color: var(--text-secondary);
text-decoration: none;
padding: 0;
}
.nav-language .lang-link:hover {
color: var(--primary);
text-decoration: underline;
}
.nav-language .lang-current {
font-weight: 600;
color: var(--text-primary);
}
.nav-language .lang-separator {
opacity: 0.4;
}
.auth-language {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 18px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.auth-language .lang-link { color: var(--text-secondary); text-decoration: none; }
.auth-language .lang-link:hover { color: var(--primary); text-decoration: underline; }
.auth-language .lang-current { font-weight: 600; color: var(--text-primary); }
.auth-language .lang-separator { opacity: 0.4; }
/* Visually hidden, still announced by screen readers. */
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Honeypot: hidden from everyone, filled in only by a robot (SEC-AUTH-008).
The opposite of .sr-only above — that one hides from the eye and keeps the
announcement, this one has to hide from both. display:none is deliberate:
an off-screen input is still reachable by keyboard and by a screen reader,
and a person who lands in it gets refused with no idea why. */
.honeypot {
display: none;
}
/* Batch Evaluation - 2 cards wide layout */
.batch-eval-form {
max-width: none;
}
.batch-eval-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 20px;
}
.batch-eval-grid .card {
margin-bottom: 0;
}
/* Allow criteria rows to wrap for a 3x3 grid inside each card */
.batch-eval-grid .form-row {
flex-wrap: wrap;
}
@media (max-width: 1100px) {
.batch-eval-grid {
grid-template-columns: 1fr;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+158
View File
@@ -437,3 +437,161 @@ document.addEventListener('DOMContentLoaded', function() {
}, 5000); }, 5000);
}); });
}); });
/* =========================================================================
Declarative behaviours — replacing inline event handlers
=========================================================================
A Content Security Policy without 'unsafe-inline' blocks `onclick="..."`
attributes, and a nonce does not help: nonces apply to <script> elements,
never to event handler attributes. Dropping 'unsafe-inline' therefore
requires removing every one of them first.
Rather than one listener per widget, behaviours are declared in the
markup with a data-action attribute and dispatched from a single
delegated listener. New markup gets the behaviour for free, and nothing
has to be re-bound after content is replaced dynamically.
<button data-action="toggle-sidebar">
<button data-action="dismiss-alert">
<div data-action="hide-modal" data-modal-id="confirmDelete">
Migration status is tracked by tests/test_csp.py.
========================================================================= */
const DATA_ACTIONS = {
'toggle-sidebar': function () {
toggleSidebar();
},
'toggle-dark-mode': function () {
toggleDarkMode();
},
'dismiss-alert': function (element) {
const alert = element.closest('.alert');
if (alert) {
alert.remove();
}
},
'hide-modal': function (element) {
const id = element.getAttribute('data-modal-id');
if (id && typeof hideModal === 'function') {
hideModal(id);
}
},
'history-back': function (element, event) {
event.preventDefault();
history.back();
},
// Removes the nearest ancestor matching data-remove, or the parent.
'remove-element': function (element) {
const selector = element.getAttribute('data-remove');
const target = selector ? element.closest(selector) : element.parentElement;
if (target) {
target.remove();
}
},
};
/**
* Register behaviours defined by a single page.
*
* Page-local functions live in that page's script block, so they cannot be
* listed in DATA_ACTIONS above. Each page declares its own:
*
* registerActions({ 'clear-availability': clearAllAvailability });
*
* @param {Object} map - action name to handler(element, event).
*/
function registerActions(map) {
Object.assign(DATA_ACTIONS, map);
}
function dispatchAction(attribute, event) {
const trigger = event.target.closest('[' + attribute + ']');
if (!trigger) {
return;
}
const handler = DATA_ACTIONS[trigger.getAttribute(attribute)];
if (handler) {
handler(trigger, event);
}
}
document.addEventListener('click', function (event) {
dispatchAction('data-action', event);
});
// Separate attribute rather than one shared with click: a <select> would
// otherwise fire its handler on the click that opens it.
document.addEventListener('change', function (event) {
dispatchAction('data-change', event);
});
/**
* Confirmation before a destructive submit.
*
* <form data-confirm="Delete this match?">
*
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
* markup where it can be translated.
*/
document.addEventListener('submit', function (event) {
const form = event.target.closest('[data-confirm]');
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
event.preventDefault();
}
});
/**
* Live value display next to a range input.
*
* <input type="range" data-mirror>
* <span>5</span>
*
* Replaces oninput="this.nextElementSibling.textContent = this.value",
* which the evaluation form repeated on all nine score sliders.
* data-mirror may name a selector; empty means the next sibling.
*/
document.addEventListener('input', function (event) {
const input = event.target.closest('[data-mirror]');
if (!input) {
return;
}
const selector = input.getAttribute('data-mirror');
const target = selector
? document.querySelector(selector)
: input.nextElementSibling;
if (target) {
target.textContent = input.value;
}
});
/**
* Submit the surrounding form when a control changes.
*
* <select data-submit-on-change>
*
* Replaces onchange="this.form.submit()".
*/
document.addEventListener('change', function (event) {
const control = event.target.closest('[data-submit-on-change]');
if (control && control.form) {
control.form.submit();
}
});
/**
* Navigate on selection.
*
* <select data-navigate="/team-matches/{value}/create">
*
* {value} is replaced by the chosen option, URL-encoded. An empty
* selection navigates nowhere.
*/
document.addEventListener('change', function (event) {
const select = event.target.closest('[data-navigate]');
if (!select || !select.value) {
return;
}
window.location.href = select.getAttribute('data-navigate')
.replace('{value}', encodeURIComponent(select.value));
});
+149
View File
@@ -0,0 +1,149 @@
"""Where the application's files live, and how the database refers to them.
Three roots: the project itself, the document store, and the log directory.
They are here together because they are the same defect three times over
(OBS-006) — `os.path.join(os.getcwd(), …)`, evaluated at import or upload
time, so every one of them moved with whatever directory the process was
started from. The document store was fixed first, in wave G, because it was
the one that also blocked OPS-011; the other two were left behind, which is
the repeated lesson of this project: a faulty pattern corrected in one layer
stays in the others.
Contracts were stored at `os.path.join(os.getcwd(), 'documents', …)`,
evaluated at upload time, and the resulting absolute path was written into
`Contract.file_path`. The storage root therefore moved with whatever
directory the process happened to be started from. Two consequences:
- one latent: start the server from elsewhere and new contracts land in a
new tree while the old ones become unreadable — with the database still
saying they are there, so the failure surfaces as a 500 on download
rather than as anything a person could act on;
- one blocking: it rules out a release-directory deployment (OPS-011)
outright. Every stored path would point inside a release that is about
to be replaced, so the first switch would take every contract ever
uploaded with it.
New rows keep a path *relative* to the document root. Old rows keep their
absolute path and are returned untouched, so this change needs no data
migration and can ship before Alembic does (DB-002).
"""
import os
#: Environment override for the document root. What a release-directory
#: deployment sets, to a path outside the releases — alongside them, not
#: inside whichever one is current.
DOCUMENTS_ROOT_ENV = 'DOCUMENTS_ROOT'
#: Environment override for the log directory. Same reasoning: a release
#: directory that carries its own logs loses them at the next switch.
LOG_DIR_ENV = 'LOG_DIR'
#: Environment override for the backup directory.
BACKUP_DIR_ENV = 'BACKUP_DIR'
#: Sub-directory holding uploaded contracts, under the document root.
CONTRACTS_DIR = 'contrats signés'
def project_root():
"""Absolute path of the project, derived from this file's location.
The anchor every other root falls back to. Not `os.getcwd()`: the
process is started by Waitress under Pterodactyl, by pytest, by a task
scheduler and by a person in a shell, and only one of those four is
reliably in the project directory.
"""
package_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.dirname(package_dir)
def documents_root():
"""Absolute path of the document store.
Falls back to `documents/` beside this package — the project root
wherever it is installed, rather than wherever the process was launched.
"""
return _rooted(DOCUMENTS_ROOT_ENV, 'documents')
def logs_root():
"""Absolute path of the log directory.
Was `os.path.join(os.getcwd(), 'logs')`. Starting the server from
another directory sent the logs somewhere new without a word, which is
the worst possible failure mode for the one file you go and read when
something else has gone wrong.
"""
return _rooted(LOG_DIR_ENV, 'logs')
def backups_root():
"""Absolute path of the backup directory."""
return _rooted(BACKUP_DIR_ENV, 'backups')
def _rooted(env_name, default_name):
"""The configured path, or `default_name` under the project root."""
configured = os.getenv(env_name)
if configured:
return os.path.abspath(configured)
return os.path.join(project_root(), default_name)
def discard_documents(stored_paths):
"""Remove these documents from disk. Returns how many went (DATA-012).
`delete_user` removed the Contract rows and left the PDFs. Signed,
named contracts therefore stayed on the server after the account was
deleted, with nothing in the database pointing at them — invisible to
the application, unmanageable through it, and still personal data.
Call this **after** the commit that removed the rows, never before: a
failure between the two should leave a file with no row (recoverable,
and what the previous behaviour produced anyway) rather than a row with
no file (a download that 500s for ever).
A path that cannot be removed is logged and skipped. Nothing here should
be able to abort the deletion of an account.
"""
import logging
logger = logging.getLogger(__name__)
removed = 0
for stored_path in stored_paths:
if not stored_path:
continue
target = document_path(stored_path)
try:
os.remove(target)
removed += 1
except FileNotFoundError:
# Already gone. Two contracts sharing a stem, or a previous
# attempt: not a problem, and not worth an error line.
logger.info('Document already absent: %s', target)
except OSError as exc:
logger.error(
'Could not remove %s (%s). It is now an orphan: no database row '
'refers to it, so nothing in the application will ever offer to '
'delete it again.',
target,
exc,
)
return removed
def document_path(stored_path):
"""Absolute path of a document, from what the database holds.
Args:
stored_path: The value of Contract.file_path or signed_file_path.
Relative for rows written since this module existed, absolute
for the ones written before.
Returns:
str: An absolute path.
"""
if os.path.isabs(stored_path):
return stored_path
return os.path.join(documents_root(), stored_path)
+319 -115
View File
@@ -1,61 +1,163 @@
"""PostgreSQL database backup and restore helpers for the admin panel. """Database and document backup for the Team Tryouts application.
Unlike the original SQLite-based script, this module works against the Dumps the PostgreSQL database with pg_dump and archives the uploaded
PostgreSQL database configured via DATABASE_URL. It shells out to the contract documents. Designed to be run from a scheduled task (Windows Task
standard ``pg_dump`` / ``pg_restore`` / ``psql`` command-line utilities. 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: Configuration via environment variables:
BACKUP_DIR: Directory to store backups (default: ./backups) DATABASE_URL PostgreSQL connection string (required)
BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30) BACKUP_DIR Where to store backups (default: ./backups)
DATABASE_URL: PostgreSQL connection string (required for connectivity) 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 os
import re import shutil
import subprocess import subprocess
import sys
from datetime import datetime, timedelta from datetime import datetime, timedelta
from urllib.parse import urlparse from urllib.parse import unquote, urlparse
BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups')) # 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)) 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.
def _database_url(): class BackupError(Exception):
"""Return the DATABASE_URL, normalizing to the psycopg2 SQLAlchemy form.""" """Raised when a backup step fails in a way that must stop the run."""
url = os.getenv('DATABASE_URL')
# ---------------------------------------------------------------------------
# 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: if not url:
raise RuntimeError('DATABASE_URL environment variable must be set') raise BackupError('DATABASE_URL is not set.')
# SQLAlchemy may prefix driver; strip it for command-line tools.
return re.sub(r'^postgresql\+[^:]+://', 'postgresql://', url) 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 _pg_env(): def describe_target(conn):
"""Return the dict of PG* variables required by pg_dump / psql.""" """Human-readable target, deliberately without the password."""
parsed = urlparse(_database_url()) 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() env = os.environ.copy()
# PGPASSWORD avoids interactive password prompts. if conn['password']:
if parsed.password is not None: env['PGPASSWORD'] = conn['password']
env['PGPASSWORD'] = parsed.password
env.pop('PGHOST', None)
env.pop('PGPORT', None)
env.pop('PGUSER', None)
env.pop('PGDATABASE', None)
return env return env
def _pg_args(): # ---------------------------------------------------------------------------
"""Return [host, port, user, dbname] positional args for pg tools.""" # Backup steps
parsed = urlparse(_database_url()) # ---------------------------------------------------------------------------
args = []
if parsed.hostname:
args += ['--host', parsed.hostname]
if parsed.port:
args += ['--port', str(parsed.port)]
if parsed.username:
args += ['--username', parsed.username]
if parsed.path and parsed.path.lstrip('/'):
args += ['--dbname', parsed.path.lstrip('/')]
return args
def create_backup_dir(): def create_backup_dir():
@@ -63,114 +165,216 @@ def create_backup_dir():
os.makedirs(BACKUP_DIR, exist_ok=True) os.makedirs(BACKUP_DIR, exist_ok=True)
def create_backup(backup_type='manual', notes=None): def backup_database(conn):
"""Create a PostgreSQL dump backup. """Dump the PostgreSQL database.
Returns: Returns:
dict: {'filename', 'file_path', 'size_bytes'} or None on failure. str: Path to the created archive.
"""
create_backup_dir()
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'db_backup_{backup_type}_{timestamp}.sql'
file_path = os.path.join(BACKUP_DIR, filename)
cmd = ['pg_dump'] + _pg_args() + ['--format', 'plain', '--no-owner', '--file', file_path]
try:
result = subprocess.run(cmd, env=_pg_env(), capture_output=True, text=True)
if result.returncode != 0:
_cleanup_failed_file(file_path)
raise RuntimeError(result.stderr.strip() or 'pg_dump failed')
except FileNotFoundError:
_cleanup_failed_file(file_path)
raise RuntimeError('pg_dump executable not found on PATH')
except Exception:
_cleanup_failed_file(file_path)
raise
if not os.path.exists(file_path):
raise RuntimeError('Backup file was not created')
size = os.path.getsize(file_path)
return {'filename': filename, 'file_path': file_path, 'size_bytes': size}
def _cleanup_failed_file(file_path):
"""Remove a partially-written dump file, ignoring errors."""
if file_path and os.path.exists(file_path):
try:
os.remove(file_path)
except OSError:
pass
def restore_backup(file_path):
"""Restore a PostgreSQL dump file using psql.
Args:
file_path: Path to a plain-text .sql dump produced by pg_dump.
Raises: Raises:
RuntimeError: If psql fails or is unavailable. BackupError: If pg_dump is missing, fails, or produces nothing.
""" """
if not file_path or not os.path.exists(file_path): timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
raise RuntimeError('Backup file does not exist') backup_path = os.path.join(BACKUP_DIR, f'db_backup_{timestamp}.dump')
print(f'[INFO] Dumping {describe_target(conn)}')
# The dump was produced with --no-owner + plain format, so we can
# pipe it into psql. Use --set ON_ERROR_STOP=1 to fail fast.
cmd = ['psql'] + _pg_args() + ['--set', 'ON_ERROR_STOP=1', '--file', file_path]
try: try:
result = subprocess.run(cmd, env=_pg_env(), capture_output=True, text=True) result = subprocess.run(
if result.returncode != 0: build_dump_command(conn, backup_path),
raise RuntimeError(result.stderr.strip() or 'psql restore failed') 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: except FileNotFoundError:
raise RuntimeError('psql executable not found on PATH') 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(): def cleanup_old_backups():
"""Remove backup files older than BACKUP_RETENTION_DAYS.""" """Remove backup files older than BACKUP_RETENTION_DAYS."""
if not os.path.exists(BACKUP_DIR): if not os.path.exists(BACKUP_DIR):
return 0 return
cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS) cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS)
removed_count = 0 removed_count = 0
for filename in os.listdir(BACKUP_DIR): for filename in os.listdir(BACKUP_DIR):
file_path = os.path.join(BACKUP_DIR, filename) file_path = os.path.join(BACKUP_DIR, filename)
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
continue continue
file_time = datetime.fromtimestamp(os.path.getmtime(file_path)) if datetime.fromtimestamp(os.path.getmtime(file_path)) >= cutoff:
if file_time < cutoff: continue
try: try:
os.remove(file_path) os.remove(file_path)
removed_count += 1 removed_count += 1
except OSError: print(f'[CLEANUP] Removed old backup: {filename}')
pass except OSError as exc:
return removed_count 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.')
def main(): # ---------------------------------------------------------------------------
"""Run a manual backup from the CLI (for cron/Task Scheduler).""" # Entry point
print('=== Team Tryouts PostgreSQL Backup ===') # ---------------------------------------------------------------------------
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'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
print(f'Backup directory: {BACKUP_DIR}') 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(f'Retention period: {BACKUP_RETENTION_DAYS} days')
print() print()
try: try:
result = create_backup(backup_type='scheduled') conn = parse_database_url(os.getenv('DATABASE_URL'))
except RuntimeError as e: create_backup_dir()
print(f'[ERROR] {e}') backup_path = backup_database(conn)
except BackupError as exc:
print(f'[ERROR] {exc}')
print('\n=== Backup FAILED — no database backup was produced ===')
return 1 return 1
print(f'[OK] Database backed up to: {result["file_path"]}') verified = verify_backup(backup_path)
documents_ok = True
removed = cleanup_old_backups() try:
if removed: backup_documents()
print(f'[CLEANUP] Removed {removed} old backup(s).') except BackupError as exc:
documents_ok = False
print(f'[ERROR] {exc}')
cleanup_old_backups()
print() print()
print('=== Backup completed successfully ===') if verified and documents_ok:
return 0 print('=== Backup completed successfully ===')
return 0
print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===')
return 1
if __name__ == '__main__': if __name__ == '__main__':
exit(main()) sys.exit(main())
+443
View File
@@ -0,0 +1,443 @@
"""Compare a live database against the models (DB-001).
Why this exists
---------------
`db.create_all()` creates missing tables and never ALTERs an existing one. A
column added to a model months ago is therefore simply absent from any
database that already had the table, and nothing says so: the application
starts, and the first query touching that column fails at runtime. The audit
called the accumulated result "les dérives" and could not measure it, because
measuring it needs the production database.
This script measures it. It is **read-only** — it opens a connection, reads
the catalogue, prints a report and exits. It issues no DDL and no DML, and
takes no locks beyond what reading `information_schema` takes.
It is the prerequisite for everything in the DB wave: `DB-002` asks for an
initial Alembic migration describing the **real** schema rather than the
models', and this is what tells you what the real schema is.
Usage
-----
# Against whatever DATABASE_URL points at
python app/supporting_scripts/schema_report.py
# Against a restored copy, which is the safe way to do it first
python app/supporting_scripts/schema_report.py \
--url postgresql://user:pass@host:5432/restored_copy
# Also look for the seeded admin/password account (SEC-003)
python app/supporting_scripts/schema_report.py --check-seed-accounts
# Find Discord identities that must be reconciled before UNIQUE (SEC-012)
python app/supporting_scripts/schema_report.py --check-discord-identities
Exit codes
----------
0 the live schema matches the models
1 drift or requested data risk found — the report says what
2 could not connect or read the catalogue
Reading the output
------------------
Findings are grouped by what they cost you:
BLOCKING the application will fail at runtime — a table or column the
models use and the database does not have.
RISK the database has something the models do not describe. Harmless
to the running application, but an Alembic autogenerate would
propose to DROP it, which is how a corrective migration deletes
a column somebody still needed.
DIFFERENCE type, nullability, default or constraint disagreements. Each one
needs a human: some are dialect spelling, some are real.
"""
import argparse
import os
import sys
# Importable as a script from the project root, like the other supporting
# scripts: `python app/supporting_scripts/schema_report.py`.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from sqlalchemy import create_engine, inspect # noqa: E402
from sqlalchemy.exc import SQLAlchemyError # noqa: E402
BLOCKING = 'BLOCKING'
RISK = 'RISK'
DIFFERENCE = 'DIFFERENCE'
class Finding:
"""One disagreement between the models and the live database."""
def __init__(self, severity, table, detail, consequence=''):
self.severity = severity
self.table = table
self.detail = detail
self.consequence = consequence
def __str__(self):
line = f' [{self.severity:10}] {self.table}: {self.detail}'
if self.consequence:
line += f'\n → {self.consequence}'
return line
def __repr__(self): # pragma: no cover — debugging aid
return f'<Finding {self.severity} {self.table} {self.detail}>'
def model_metadata():
"""The schema the models describe.
Imports app.models for its side effect: importing the modules is what
registers every table on the shared metadata.
"""
from app.extensions import db
from app.models import User # noqa: F401 — registers the whole model package
return db.metadata
def _type_of(column_type, dialect):
"""A type as this dialect spells it, so the two sides are comparable.
Comparing `String(200)` with `VARCHAR(200)` as strings would report every
column as different. Compiling both against the same dialect makes the
comparison mean something.
"""
try:
return column_type.compile(dialect=dialect)
except Exception: # noqa: BLE001 — an uncompilable type is still reportable
return str(column_type)
def compare_tables(metadata, inspector):
"""Tables the models expect against tables the database has."""
findings = []
model_tables = set(metadata.tables)
live_tables = set(inspector.get_table_names())
for name in sorted(model_tables - live_tables):
findings.append(
Finding(
BLOCKING,
name,
'table is missing from the database',
'every query against this model fails. create_all() would '
'create it — which is why the absence can survive unnoticed '
'on a machine where AUTO_CREATE_TABLES is on.',
)
)
for name in sorted(live_tables - model_tables):
findings.append(
Finding(
RISK,
name,
'table exists in the database and in no model',
'an Alembic autogenerate would propose to DROP it. Decide '
'before running one: it may be a leftover, or it may be the '
'only copy of something.',
)
)
return findings, sorted(model_tables & live_tables)
def compare_columns(metadata, inspector, table_name, dialect):
"""Column-by-column, for one table."""
findings = []
model_columns = {c.name: c for c in metadata.tables[table_name].columns}
live_columns = {c['name']: c for c in inspector.get_columns(table_name)}
for name in sorted(set(model_columns) - set(live_columns)):
column = model_columns[name]
findings.append(
Finding(
BLOCKING,
table_name,
f'column "{name}" is in the model and not in the database',
'this is exactly what create_all() cannot fix: it never '
'ALTERs. Any query selecting or writing this column fails.'
+ (
''
if column.nullable
else ' The column is NOT NULL, so the '
'corrective migration needs a default or a backfill.'
),
)
)
for name in sorted(set(live_columns) - set(model_columns)):
findings.append(
Finding(
RISK,
table_name,
f'column "{name}" is in the database and not in any model',
'an autogenerated migration would propose to DROP it, taking '
'its data. Check whether something outside the application '
'reads it before agreeing.',
)
)
for name in sorted(set(model_columns) & set(live_columns)):
model_column, live_column = model_columns[name], live_columns[name]
model_type = _type_of(model_column.type, dialect)
live_type = _type_of(live_column['type'], dialect)
if model_type != live_type:
findings.append(
Finding(
DIFFERENCE,
table_name,
f'column "{name}" type: model says {model_type}, database says {live_type}',
'a narrower column in the database silently truncates or '
'rejects; a wider one is usually harmless.',
)
)
if bool(model_column.nullable) != bool(live_column.get('nullable', True)):
findings.append(
Finding(
DIFFERENCE,
table_name,
f'column "{name}" nullability: model says '
f'{"NULL" if model_column.nullable else "NOT NULL"}, database says '
f'{"NULL" if live_column.get("nullable", True) else "NOT NULL"}',
'a NOT NULL the database does not enforce is a constraint '
'the application only believes it has.',
)
)
return findings
def compare_constraints(metadata, inspector, table_name):
"""Unique constraints, indexes and foreign keys.
Named constraints are compared by the columns they cover rather than by
name: the same rule declared under two names is the same rule, and
reporting it as a difference would bury the ones that matter.
"""
findings = []
table = metadata.tables[table_name]
def column_sets(entries, key):
return {tuple(sorted(entry[key] or [])) for entry in entries}
model_unique = {
tuple(sorted(c.name for c in constraint.columns))
for constraint in table.constraints
if constraint.__class__.__name__ == 'UniqueConstraint'
}
live_unique = column_sets(inspector.get_unique_constraints(table_name), 'column_names')
for columns in sorted(model_unique - live_unique):
findings.append(
Finding(
DIFFERENCE,
table_name,
f'unique constraint on {list(columns)} is declared and absent from the database',
'the application believes duplicates are impossible here. '
'They are not, and two concurrent requests will prove it.',
)
)
model_fks = {
tuple(sorted(fk.parent.name for fk in constraint.elements))
for constraint in table.foreign_key_constraints
}
live_fks = column_sets(inspector.get_foreign_keys(table_name), 'constrained_columns')
for columns in sorted(model_fks - live_fks):
findings.append(
Finding(
DIFFERENCE,
table_name,
f'foreign key on {list(columns)} is declared and absent from the database',
'orphan rows are possible, and ON DELETE behaviour is not '
'being enforced by the database at all.',
)
)
model_indexes = {tuple(sorted(c.name for c in index.columns)) for index in table.indexes}
live_indexes = column_sets(inspector.get_indexes(table_name), 'column_names')
for columns in sorted(model_indexes - live_indexes):
findings.append(
Finding(
DIFFERENCE,
table_name,
f'index on {list(columns)} is declared and absent from the database',
'correctness is unaffected; the queries that rely on it are '
'doing sequential scans.',
)
)
return findings
def collect_findings(engine):
"""Every disagreement between the models and this database."""
metadata = model_metadata()
inspector = inspect(engine)
findings, shared_tables = compare_tables(metadata, inspector)
for table_name in shared_tables:
findings.extend(compare_columns(metadata, inspector, table_name, engine.dialect))
findings.extend(compare_constraints(metadata, inspector, table_name))
return findings
def find_seed_accounts(engine):
"""Accounts matching the credentials clear_db.py used to seed (SEC-003).
The script was removed from the deployment, but it had already been run:
the audit could not tell whether an `admin` account with the password
`password` still exists in production, and that question cannot be
answered from the repository.
Returns:
list[tuple]: (username, role, whether the known password matches).
"""
from sqlalchemy import text
from app.extensions import check_password
with engine.connect() as connection:
rows = connection.execute(
text('SELECT username, role, password_hash FROM users WHERE username = :name'),
{'name': 'admin'},
).fetchall()
results = []
for username, role, password_hash in rows:
try:
matches = check_password(password_hash, 'password')
except Exception: # noqa: BLE001 — an unreadable hash is not a match
matches = False
results.append((username, role, matches))
return results
def find_duplicate_discord_identities(engine):
"""Discord snowflakes claimed by more than one account (SEC-012).
New links are now refused in application code, but existing production
rows predate that guard. These groups must be reconciled before Alembic
can add the database-level UNIQUE constraint.
Returns:
list[tuple]: (discord_user_id, comma-separated usernames, count).
"""
from sqlalchemy import text
with engine.connect() as connection:
rows = connection.execute(
text(
'SELECT discord_user_id, COUNT(*) AS account_count '
'FROM users '
"WHERE discord_user_id IS NOT NULL AND discord_user_id <> '' "
'GROUP BY discord_user_id HAVING COUNT(*) > 1 '
'ORDER BY discord_user_id'
)
).fetchall()
duplicates = []
for discord_user_id, account_count in rows:
usernames = connection.execute(
text(
'SELECT username FROM users '
'WHERE discord_user_id = :discord_user_id ORDER BY username'
),
{'discord_user_id': discord_user_id},
).scalars()
duplicates.append((discord_user_id, ', '.join(usernames), account_count))
return duplicates
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
parser.add_argument(
'--url',
default=os.getenv('DATABASE_URL'),
help='Database URL. Defaults to DATABASE_URL. Point it at a restored copy the first time.',
)
parser.add_argument(
'--check-seed-accounts',
action='store_true',
help='Also look for the admin/password account seeded by clear_db.py (SEC-003).',
)
parser.add_argument(
'--check-discord-identities',
action='store_true',
help='Find duplicate Discord IDs that block the SEC-012 UNIQUE constraint.',
)
args = parser.parse_args(argv)
if not args.url:
print('No database URL. Pass --url or set DATABASE_URL.', file=sys.stderr)
return 2
from app.app import normalise_database_url
try:
engine = create_engine(normalise_database_url(args.url))
findings = collect_findings(engine)
except SQLAlchemyError as exc:
print(f'Could not read the schema: {exc}', file=sys.stderr)
return 2
print('=' * 78)
print('Schema report — models vs live database (DB-001)')
print('=' * 78)
if not findings:
print('\nNo drift. The live schema matches the models.')
for severity in (BLOCKING, RISK, DIFFERENCE):
group = [f for f in findings if f.severity == severity]
if not group:
continue
print(f'\n{severity} — {len(group)} finding(s)')
for finding in group:
print(finding)
if args.check_seed_accounts:
print('\n' + '=' * 78)
print('Seeded accounts (SEC-003)')
print('=' * 78)
try:
accounts = find_seed_accounts(engine)
except SQLAlchemyError as exc:
print(f'Could not check: {exc}')
else:
if not accounts:
print('No account named "admin".')
for username, role, matches in accounts:
verdict = (
'PASSWORD IS STILL "password" — change it now'
if matches
else 'password has been changed'
)
print(f' {username} ({role}): {verdict}')
duplicate_discord_identities = []
if args.check_discord_identities:
print('\n' + '=' * 78)
print('Duplicate Discord identities (SEC-012)')
print('=' * 78)
try:
duplicate_discord_identities = find_duplicate_discord_identities(engine)
except SQLAlchemyError as exc:
print(f'Could not check: {exc}')
else:
if not duplicate_discord_identities:
print('No Discord identity is shared by multiple accounts.')
for discord_user_id, usernames, account_count in duplicate_discord_identities:
print(f' {discord_user_id}: {account_count} accounts ({usernames})')
blocking = sum(1 for f in findings if f.severity == BLOCKING)
print(f'\n{len(findings)} finding(s), {blocking} blocking.')
return 1 if findings or duplicate_discord_identities else 0
if __name__ == '__main__': # pragma: no cover
sys.exit(main())
+113 -56
View File
@@ -6,19 +6,23 @@ This script performs pre-deployment security checks to validate:
- Debug mode status - Debug mode status
- HTTPS configuration - HTTPS configuration
- Dependency vulnerabilities - Dependency vulnerabilities
- Database connectivity - Required database configuration
Usage: Usage:
python security_scan.py [--url http://localhost:5000] python security_scan.py [--url http://localhost:5000]
""" """
import os
import sys
import json import json
import subprocess import os
import urllib.request
import ssl import ssl
import subprocess
import sys
import urllib.request
from datetime import datetime from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
def check_environment(): def check_environment():
@@ -31,8 +35,8 @@ def check_environment():
print('1. ENVIRONMENT VARIABLES CHECK') print('1. ENVIRONMENT VARIABLES CHECK')
print('=' * 60) print('=' * 60)
critical_vars = ['SECRET_KEY'] critical_vars = ['SECRET_KEY', 'DATABASE_URL']
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS'] recommended_vars = ['CORS_ALLOWED_ORIGINS']
all_ok = True all_ok = True
for var in critical_vars: for var in critical_vars:
@@ -58,7 +62,8 @@ def check_environment():
# Check FLASK_DEBUG # Check FLASK_DEBUG
debug = os.getenv('FLASK_DEBUG', 'false').lower() debug = os.getenv('FLASK_DEBUG', 'false').lower()
if debug == 'true': if debug == 'true':
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.') print('[FAIL] FLASK_DEBUG is enabled! It must be disabled in production.')
all_ok = False
else: else:
print('[OK] FLASK_DEBUG is disabled') print('[OK] FLASK_DEBUG is disabled')
@@ -91,10 +96,11 @@ def check_https_headers(url):
all_ok = True all_ok = True
try: try:
# Create a context that doesn't verify SSL (for local testing) # Keep the default certificate and hostname verification. A scanner
# that accepts an invalid certificate can validate headers while the
# transport itself is impersonated. Local runs without TLS should use
# http:// explicitly or opt out with --skip-http.
ctx = ssl.create_default_context() ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, method='HEAD') req = urllib.request.Request(url, method='HEAD')
@@ -128,7 +134,9 @@ def check_https_headers(url):
all_ok = False all_ok = False
if 'SameSite' in cookie: if 'SameSite' in cookie:
print(f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}') print(
f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}'
)
else: else:
print('[WARN] Cookies missing SameSite attribute') print('[WARN] Cookies missing SameSite attribute')
all_ok = False all_ok = False
@@ -146,9 +154,9 @@ def check_https_headers(url):
all_ok = False all_ok = False
except urllib.error.URLError as e: except urllib.error.URLError as e:
print(f'[SKIP] Cannot connect to {url}: {e.reason}') print(f'[FAIL] Cannot connect to {url}: {e.reason}')
print('[SKIP] Run with --url <application_url> to check headers') print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
return True # Not a failure, just can't check return False
return all_ok return all_ok
@@ -165,38 +173,52 @@ def check_dependencies():
try: try:
result = subprocess.run( result = subprocess.run(
[sys.executable, '-m', 'pip_audit', '--format', 'json'], [
sys.executable,
'-m',
'pip_audit',
'--requirement',
str(REQUIREMENTS_FILE),
'--format',
'json',
],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60 timeout=60,
) )
if result.returncode == 0: if result.returncode == 0:
print('[OK] No known vulnerabilities found') print('[OK] No known vulnerabilities found')
return True return True
else: try:
try: data = json.loads(result.stdout)
data = json.loads(result.stdout) # pip-audit's "dependencies" array lists EVERY dependency, each
vulns = data.get('dependencies', []) # carrying a "vulns" list that is empty when the package is
if vulns: # clean. Treating the array itself as the vulnerability list
for vuln in vulns: # reported all ~45 installed packages as vulnerable on every
print(f'[FAIL] {vuln["name"]}=={vuln["version"]}: {vuln.get("description", "Vulnerability found")}') # run, which is why this check was pure noise.
return False affected = [dep for dep in data.get('dependencies', []) if dep.get('vulns')]
else: if affected:
print('[OK] No vulnerabilities found') for dep in affected:
return True ids = ', '.join(v.get('id', '?') for v in dep.get('vulns', []))
except json.JSONDecodeError: print(f'[FAIL] {dep["name"]}=={dep["version"]}: {ids}')
if result.stdout: return False
print(f'[INFO] {result.stdout.strip()}') print('[OK] No vulnerabilities found')
if result.stderr: return True
print(f'[WARN] {result.stderr.strip()}') except json.JSONDecodeError:
return True if result.stdout:
print(f'[INFO] {result.stdout.strip()}')
if result.stderr:
print(f'[FAIL] {result.stderr.strip()}')
else:
print(f'[FAIL] pip-audit exited with status {result.returncode}.')
return False
except FileNotFoundError: except FileNotFoundError:
print('[SKIP] pip-audit not installed. Run: pip install pip-audit') print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
return True return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
print('[WARN] pip-audit timed out') print('[FAIL] pip-audit timed out')
return True return False
def check_file_permissions(): def check_file_permissions():
@@ -215,7 +237,7 @@ def check_file_permissions():
gitignore_path = os.path.join(os.getcwd(), '.gitignore') gitignore_path = os.path.join(os.getcwd(), '.gitignore')
if os.path.exists(gitignore_path): if os.path.exists(gitignore_path):
required_patterns = ['.env', 'instance/', '*.db', '*.log'] required_patterns = ['.env', 'instance/', '*.db', '*.log']
with open(gitignore_path, 'r') as f: with open(gitignore_path) as f:
content = f.read() content = f.read()
for pattern in required_patterns: for pattern in required_patterns:
@@ -237,7 +259,7 @@ def check_file_permissions():
# Check for leftover .pyc or __pycache__ # Check for leftover .pyc or __pycache__
pycache_count = 0 pycache_count = 0
for root, dirs, files in os.walk(os.getcwd()): for _root, dirs, files in os.walk(os.getcwd()):
if '__pycache__' in dirs: if '__pycache__' in dirs:
pycache_count += 1 pycache_count += 1
for f in files: for f in files:
@@ -264,8 +286,22 @@ def check_flask_config():
all_ok = True all_ok = True
try: try:
# The script lives two levels below the project root; without this the
# import fails and the whole check was silently skipped.
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if root not in sys.path:
sys.path.insert(0, root)
from app.app import create_app from app.app import create_app
app = create_app()
# Inspect configuration only: no schema creation, no Discord bot.
app = create_app(
{
'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:',
'AUTO_CREATE_TABLES': False,
'ENABLE_DISCORD_BOT': False,
}
)
# Check session cookie settings # Check session cookie settings
cookie_checks = [ cookie_checks = [
@@ -312,8 +348,17 @@ def check_flask_config():
else: else:
print('[OK] DEBUG mode: disabled') print('[OK] DEBUG mode: disabled')
except Exception as e: except Exception as e: # noqa: BLE001 — any failure to load the app is a failed check
print(f'[SKIP] Cannot check Flask config: {e}') # Returning all_ok (still True) here meant that failing to load the
# application at all was counted as a passing check — the most
# important section of the report silently never ran.
#
# The breadth is the point: this section's question is "does the
# application load with a safe configuration", and every way of not
# loading answers it the same way. Reported on stdout because this
# script is read by a CI job, not by a log collector.
print(f'[FAIL] Cannot check Flask config: {e}')
return False
return all_ok return all_ok
@@ -327,19 +372,32 @@ def main():
import argparse import argparse
parser = argparse.ArgumentParser(description='Security validation scanner') parser = argparse.ArgumentParser(description='Security validation scanner')
parser.add_argument('--url', default='http://localhost:5000', parser.add_argument(
help='Application URL to check headers (default: http://localhost:5000)') '--url',
default='http://localhost:5000',
help='Application URL to check headers (default: http://localhost:5000)',
)
parser.add_argument(
'--skip-http',
action='store_true',
help='Skip the live HTTP header check (no server running, e.g. in CI)',
)
args = parser.parse_args() args = parser.parse_args()
print('╔══════════════════════════════════════════════════════════╗') # Plain ASCII: the box-drawing characters this banner used crashed the
print('║ TEAM TRYOUTS - SECURITY VALIDATION SCANNER ║') # script outright on a cp1252 Windows console, which is the platform the
print('╠══════════════════════════════════════════════════════════╣') # project is developed and deployed on.
print(f'║ Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') print('=' * 60)
print('╚══════════════════════════════════════════════════════════╝') print('TEAM TRYOUTS - SECURITY VALIDATION SCANNER')
print(f'Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
print('=' * 60)
checks = [ checks = [check_environment]
check_environment, if args.skip_http:
lambda: check_https_headers(args.url), print('\n[SKIP] HTTP header check disabled via --skip-http')
else:
checks.append(lambda: check_https_headers(args.url))
checks += [
check_dependencies, check_dependencies,
check_file_permissions, check_file_permissions,
check_flask_config, check_flask_config,
@@ -363,9 +421,8 @@ def main():
if failed == 0: if failed == 0:
print('\n[OK] All security checks passed!') print('\n[OK] All security checks passed!')
return 0 return 0
else: print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
print(f'\n[WARN] {failed} check(s) failed. Review the output above.') return 1
return 1
if __name__ == '__main__': if __name__ == '__main__':
+5 -5
View File
@@ -1,15 +1,15 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% block title %}400 Bad Request - TryoutPro{% endblock %} {% block title %}{{ _('400 Bad Request') }} - UdeS team manager{% endblock %}
{% block page_title %}Bad Request{% endblock %} {% block page_title %}{{ _('Bad Request') }}{% endblock %}
{% block content %} {% block content %}
<div class="error-container"> <div class="error-container">
<div class="error-icon"> <div class="error-icon">
<i class="fas fa-exclamation-triangle"></i> <i class="fas fa-exclamation-triangle"></i>
</div> </div>
<h2>400 — Bad Request</h2> <h2>{{ _('400 — Bad Request') }}</h2>
<p>The request could not be understood by the server. Please check your input and try again.</p> <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"> <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 <i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a> </a>
</div> </div>
{% endblock %} {% endblock %}
+5 -5
View File
@@ -1,15 +1,15 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% block title %}403 Forbidden - TryoutPro{% endblock %} {% block title %}{{ _('403 Forbidden') }} - UdeS team manager{% endblock %}
{% block page_title %}Access Denied{% endblock %} {% block page_title %}{{ _('Access Denied') }}{% endblock %}
{% block content %} {% block content %}
<div class="error-container"> <div class="error-container">
<div class="error-icon"> <div class="error-icon">
<i class="fas fa-lock"></i> <i class="fas fa-lock"></i>
</div> </div>
<h2>403 — Forbidden</h2> <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> <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"> <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 <i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a> </a>
</div> </div>
{% endblock %} {% endblock %}
+5 -5
View File
@@ -1,15 +1,15 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% block title %}404 Not Found - TryoutPro{% endblock %} {% block title %}{{ _('404 Not Found') }} - UdeS team manager{% endblock %}
{% block page_title %}Page Not Found{% endblock %} {% block page_title %}{{ _('Page Not Found') }}{% endblock %}
{% block content %} {% block content %}
<div class="error-container"> <div class="error-container">
<div class="error-icon"> <div class="error-icon">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
</div> </div>
<h2>404 — Not Found</h2> <h2>{{ _('404 — Not Found') }}</h2>
<p>The page you are looking for does not exist. It may have been moved or deleted.</p> <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"> <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 <i class="fas fa-home"></i> {{ _('Return Home') }}
</a> </a>
</div> </div>
{% endblock %} {% endblock %}
+5 -5
View File
@@ -1,15 +1,15 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% block title %}429 Too Many Requests - TryoutPro{% endblock %} {% block title %}{{ _('429 Too Many Requests') }} - UdeS team manager{% endblock %}
{% block page_title %}Rate Limit Exceeded{% endblock %} {% block page_title %}{{ _('Rate Limit Exceeded') }}{% endblock %}
{% block content %} {% block content %}
<div class="error-container"> <div class="error-container">
<div class="error-icon"> <div class="error-icon">
<i class="fas fa-hourglass-half"></i> <i class="fas fa-hourglass-half"></i>
</div> </div>
<h2>429 — Too Many Requests</h2> <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> <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"> <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 <i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a> </a>
</div> </div>
{% endblock %} {% endblock %}
+11 -5
View File
@@ -1,15 +1,21 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% block title %}500 Server Error - TryoutPro{% endblock %} {% block title %}{{ _('500 Server Error') }} - UdeS team manager{% endblock %}
{% block page_title %}Internal Server Error{% endblock %} {% block page_title %}{{ _('Internal Server Error') }}{% endblock %}
{% block content %} {% block content %}
<div class="error-container"> <div class="error-container">
<div class="error-icon"> <div class="error-icon">
<i class="fas fa-cogs"></i> <i class="fas fa-cogs"></i>
</div> </div>
<h2>500 — Internal Server Error</h2> <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> <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"> <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 <i class="fas fa-redo-alt"></i> {{ _('Try Again') }}
</a> </a>
</div> </div>
{% endblock %} {% endblock %}
@@ -0,0 +1,19 @@
{# Language switcher.
Included from both branches of the layout: the signed-in sidebar and the
anonymous authentication page. Someone who cannot read the current
language has to be able to change it *before* signing in, so this cannot
live behind the login.
Each language is written in its own language, for the same reason. #}
<i class="fas fa-language" aria-hidden="true"></i>
<span class="sr-only">{{ _('Language') }}</span>
{% for code in supported_locales %}
{%- if code == current_locale %}
<span class="lang-current" aria-current="true">{{ locale_names[code] }}</span>
{%- else %}
<a href="{{ url_for('main.set_language', locale=code) }}"
class="lang-link" hreflang="{{ code }}" rel="alternate">{{ locale_names[code] }}</a>
{%- endif %}
{%- if not loop.last %}<span class="lang-separator" aria-hidden="true">·</span>{% endif %}
{% endfor %}
+41
View File
@@ -0,0 +1,41 @@
{#
Pagination controls (MNT-14).
Import and call:
{% import 'layouts/_pagination.html' as pager %}
{{ pager.controls(pagination) }}
`page_url` is a Jinja global registered in app.py; it rebuilds the current
URL at another page number, keeping the rest of the query string. That is
the part that gets forgotten — dropping `sort` or `team_id` from a
pagination link silently resets the view someone was looking at.
Plain <a> links only: no inline handler, nothing for CSP to refuse
(tests/test_csp.py).
#}
{% macro controls(pagination) %}
{% if pagination.pages > 1 %}
<nav class="pagination" aria-label="{{ _('Pagination') }}">
{% if pagination.has_prev %}
<a class="btn btn-secondary btn-sm" href="{{ page_url(pagination.prev_num) }}"
rel="prev">&laquo; {{ _('Previous') }}</a>
{% else %}
<span class="btn btn-secondary btn-sm is-disabled" aria-disabled="true">&laquo; {{ _('Previous') }}</span>
{% endif %}
<span class="pagination-status">
{{ _('Page %(page)s of %(pages)s', page=pagination.page, pages=pagination.pages) }}
&middot;
{{ _('%(total)s in total', total=pagination.total) }}
</span>
{% if pagination.has_next %}
<a class="btn btn-secondary btn-sm" href="{{ page_url(pagination.next_num) }}"
rel="next">{{ _('Next') }} &raquo;</a>
{% else %}
<span class="btn btn-secondary btn-sm is-disabled" aria-disabled="true">{{ _('Next') }} &raquo;</span>
{% endif %}
</nav>
{% endif %}
{% endmacro %}
+73 -28
View File
@@ -1,10 +1,25 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{{ current_locale }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Team Tryout Management{% endblock %}</title> <title>{% block title %}UdeS team manager{% endblock %}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> {# Subresource integrity (QUA-004). Without it, whoever controls the CDN
controls what runs on every page of this site — and the CSP names these
hosts as allowed, so it would not object.
What SRI does and does not do: it pins this exact file, so the browser
refuses a version that has been altered since. It does not prove the
file was honest when the hash was taken. This hash is the one cdnjs
publishes for the release, not one derived from the copy we downloaded.
integrity requires crossorigin. Changing the version means changing
the hash, or the asset silently stops loading. #}
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous"
referrerpolicy="no-referrer">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏆</text></svg>"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏆</text></svg>">
</head> </head>
@@ -13,8 +28,8 @@
<nav class="sidebar" id="sidebar"> <nav class="sidebar" id="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo"> <div class="logo">
<i class="fas fa-trophy"></i> <img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="logo-img">
<span>TryoutPro</span> <span>UdeS team manager</span>
</div> </div>
<div class="user-badge"> <div class="user-badge">
<div class="user-avatar"> <div class="user-avatar">
@@ -30,26 +45,26 @@
<li> <li>
<a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint and 'dashboard' in request.endpoint %}active{% endif %}"> <a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint and 'dashboard' in request.endpoint %}active{% endif %}">
<i class="fas fa-th-large"></i> <i class="fas fa-th-large"></i>
<span>Dashboard</span> <span>{{ _('Dashboard') }}</span>
</a> </a>
</li> </li>
<li> <li>
<a href="{{ url_for('tryouts.list_tryouts') }}" class="{% if request.endpoint and 'tryouts' in request.endpoint and request.endpoint != 'tryouts.create_tryout' %}active{% endif %}"> <a href="{{ url_for('tryouts.list_tryouts') }}" class="{% if request.endpoint and 'tryouts' in request.endpoint and request.endpoint != 'tryouts.create_tryout' %}active{% endif %}">
<i class="fas fa-calendar-alt"></i> <i class="fas fa-calendar-alt"></i>
<span>Tryouts</span> <span>{{ _('Tryouts') }}</span>
</a> </a>
</li> </li>
<li> <li>
<a href="{{ url_for('matches.calendar') }}" class="{% if request.endpoint and 'calendar' in request.endpoint %}active{% endif %}"> <a href="{{ url_for('matches.calendar') }}" class="{% if request.endpoint and 'calendar' in request.endpoint %}active{% endif %}">
<i class="fas fa-calendar"></i> <i class="fas fa-calendar"></i>
<span>Calendar</span> <span>{{ _('Calendar') }}</span>
</a> </a>
</li> </li>
{% if current_user.can_evaluate() %} {% if current_user.can_evaluate() %}
<li> <li>
<a href="{{ url_for('evaluations.list_evaluations') }}" class="{% if request.endpoint and 'evaluations' in request.endpoint %}active{% endif %}"> <a href="{{ url_for('evaluations.list_evaluations') }}" class="{% if request.endpoint and 'evaluations' in request.endpoint %}active{% endif %}">
<i class="fas fa-clipboard-check"></i> <i class="fas fa-clipboard-check"></i>
<span>Evaluations</span> <span>{{ _('Evaluations') }}</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -57,14 +72,14 @@
<li> <li>
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}"> <a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
<i class="fas fa-users"></i> <i class="fas fa-users"></i>
<span>My Team(s)</span> <span>{{ _('My Team(s)') }}</span>
</a> </a>
</li> </li>
{% else %} {% else %}
<li> <li>
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}"> <a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}">
<i class="fas fa-users-cog"></i> <i class="fas fa-users-cog"></i>
<span>Manage Teams</span> <span>{{ _('Manage Teams') }}</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -72,7 +87,7 @@
<li> <li>
<a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}"> <a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}">
<i class="fas fa-users-cog"></i> <i class="fas fa-users-cog"></i>
<span>Manage Users</span> <span>{{ _('Manage Users') }}</span>
</a> </a>
</li> </li>
<li> <li>
@@ -86,7 +101,7 @@
<li> <li>
<a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}"> <a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}">
<i class="fas fa-sticky-note"></i> <i class="fas fa-sticky-note"></i>
<span>My Notes</span> <span>{{ _('My Notes') }}</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -94,42 +109,53 @@
<li> <li>
<a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}"> <a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}">
<i class="fas fa-sticky-note"></i> <i class="fas fa-sticky-note"></i>
<span>Notes & One on One</span> <span>{{ _('Notes & One on One') }}</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
<li> <li>
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}"> <a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
<i class="fas fa-file-contract"></i> <i class="fas fa-file-contract"></i>
<span>Contracts</span> <span>{{ _('Contracts') }}</span>
</a> </a>
</li> </li>
<li class="nav-divider"></li> <li class="nav-divider"></li>
<li> <li>
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}"> <a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
<i class="fas fa-user"></i> <i class="fas fa-user"></i>
<span>My Profile</span> <span>{{ _('My Profile') }}</span>
</a> </a>
</li> </li>
<li> <li>
<a href="{{ url_for('auth.logout') }}" class="logout-link"> {# A form, not a link: logging out is a state change, and a
<i class="fas fa-sign-out-alt"></i> GET route carries no CSRF token — any site could sign the
<span>Logout</span> user out with an <img> tag. Styled as a nav entry. #}
</a> <form method="POST" action="{{ url_for('auth.logout') }}" class="nav-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="logout-link">
<i class="fas fa-sign-out-alt"></i>
<span>{{ _('Logout') }}</span>
</button>
</form>
</li>
<li class="nav-divider"></li>
<li class="nav-language">
{% include "layouts/_language_switcher.html" %}
</li> </li>
</ul> </ul>
</nav> </nav>
<div class="main-content" id="mainContent"> <div class="main-content" id="mainContent">
<header class="top-bar"> <header class="top-bar">
<button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebar()"> <button class="sidebar-toggle" id="sidebarToggle" data-action="toggle-sidebar">
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </button>
<div class="page-header"> <div class="page-header">
<h1>{% block page_title %}Dashboard{% endblock %}</h1> <h1>{% block page_title %}{{ _('Dashboard') }}{% endblock %}</h1>
{% block breadcrumb %}{% endblock %} {% block breadcrumb %}{% endblock %}
</div> </div>
<button class="dark-mode-toggle" id="darkModeToggle" onclick="toggleDarkMode()" title="Toggle dark mode"> <button class="dark-mode-toggle" id="darkModeToggle" data-action="toggle-dark-mode"
title="{{ _('Toggle dark mode') }}">
<i class="fas fa-moon"></i> <i class="fas fa-moon"></i>
</button> </button>
{% block header_actions %}{% endblock %} {% block header_actions %}{% endblock %}
@@ -140,7 +166,8 @@
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible"> <div class="alert alert-{{ category }} alert-dismissible">
<span>{{ message }}</span> <span>{{ message }}</span>
<button type="button" class="alert-close" onclick="this.parentElement.remove()">&times;</button> <button type="button" class="alert-close" data-action="dismiss-alert"
aria-label="{{ _('Dismiss') }}">&times;</button>
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@@ -158,7 +185,8 @@
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible"> <div class="alert alert-{{ category }} alert-dismissible">
<span>{{ message }}</span> <span>{{ message }}</span>
<button type="button" class="alert-close" onclick="this.parentElement.remove()">&times;</button> <button type="button" class="alert-close" data-action="dismiss-alert"
aria-label="{{ _('Dismiss') }}">&times;</button>
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@@ -166,10 +194,27 @@
</div> </div>
<div class="auth-container"> <div class="auth-container">
<div class="auth-header"> <div class="auth-header">
<i class="fas fa-trophy"></i> <img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="auth-logo-img">
<h2>TryoutPro</h2> <h2>UdeS team manager</h2>
<p>Team Tryout Management System</p> <p>{{ _('UdeS team manager') }}</p>
</div> </div>
<div class="auth-language">
{% include "layouts/_language_switcher.html" %}
</div>
{# The same `content` block as the signed-in branch, rendered
here too — `self.content()` rather than a second
`{% block %}`, which Jinja refuses.
The error pages (400, 403, 404, 429, 500) all fill `content`,
and it existed only inside the `is_authenticated` branch: a
signed-out visitor hitting any of them got the logo, the
language switcher and no message whatsoever. The <title> still
said "404", which is most of why nobody noticed.
Only one branch of the `if` runs, so this never double-renders.
Sign-in pages fill `auth_content` instead and leave this
empty. #}
{{ self.content() }}
{% block auth_content %}{% endblock %} {% block auth_content %}{% endblock %}
</div> </div>
</div> </div>
+4 -3
View File
@@ -7,7 +7,7 @@
{# Page Header Macro - renders title and breadcrumb #} {# Page Header Macro - renders title and breadcrumb #}
{% macro page_header(title, breadcrumb) %} {% macro page_header(title, breadcrumb) %}
{% block title %}{{ title }} - TryoutPro{% endblock %} {% block title %}{{ title }} - UdeS team manager{% endblock %}
{% block page_title %}{{ title }}{% endblock %} {% block page_title %}{{ title }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ breadcrumb }}</span>{% endblock %} {% block breadcrumb %}<span class="breadcrumb">{{ breadcrumb }}</span>{% endblock %}
{% endmacro %} {% endmacro %}
@@ -108,11 +108,12 @@
{# Modal Macro - renders a modal dialog #} {# Modal Macro - renders a modal dialog #}
{% macro modal(id, title, content, footer_buttons=None) %} {% macro modal(id, title, content, footer_buttons=None) %}
<div id="{{ id }}" class="modal hidden"> <div id="{{ id }}" class="modal hidden">
<div class="modal-backdrop" onclick="hideModal('{{ id }}')"></div> <div class="modal-backdrop" data-action="hide-modal" data-modal-id="{{ id }}"></div>
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>{{ title }}</h3> <h3>{{ title }}</h3>
<button class="modal-close" onclick="hideModal('{{ id }}')">&times;</button> <button class="modal-close" data-action="hide-modal" data-modal-id="{{ id }}"
aria-label="{{ _('Close') }}">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{{ content }} {{ content }}
+15 -15
View File
@@ -1,6 +1,6 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% block title %}Add Personal Note - TryoutPro{% endblock %} {% block title %}{{ _('Add Personal Note') }} - UdeS team manager{% endblock %}
{% block page_title %}Add Personal Note{% endblock %} {% block page_title %}{{ _('Add Personal Note') }}{% endblock %}
{% block breadcrumb %} {% block breadcrumb %}
<span class="breadcrumb"> <span class="breadcrumb">
Home / <a href="{{ url_for('tryouts.list_tryouts') }}">Tryouts</a> Home / <a href="{{ url_for('tryouts.list_tryouts') }}">Tryouts</a>
@@ -19,7 +19,7 @@
{% block content %} {% block content %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-sticky-note"></i> Add Personal Note</h3> <h3><i class="fas fa-sticky-note"></i> {{ _('Add Personal Note') }}</h3>
{% if context_type == 'tryout' %} {% if context_type == 'tryout' %}
<p class="text-muted small">Context: <strong>Tryout - {{ tryout.title }}</strong></p> <p class="text-muted small">Context: <strong>Tryout - {{ tryout.title }}</strong></p>
{% elif context_type == 'match' %} {% elif context_type == 'match' %}
@@ -44,9 +44,9 @@
{% endif %} {% endif %}
<div class="form-group"> <div class="form-group">
<label for="player_id">Player</label> <label for="player_id">{{ _('Player') }}</label>
<select name="player_id" id="player_id" class="form-select" required> <select name="player_id" id="player_id" class="form-select" required>
<option value="">-- Select a player --</option> <option value="">{{ _('-- Select a player --') }}</option>
{% for player in players %} {% for player in players %}
<option value="{{ player.id }}" {% if preselected_player_id == player.id %}selected{% endif %}> <option value="{{ player.id }}" {% if preselected_player_id == player.id %}selected{% endif %}>
{{ player.username }} {{ player.username }}
@@ -56,15 +56,15 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="content">Note Content</label> <label for="content">{{ _('Note Content') }}</label>
<textarea name="content" id="content" rows="6" class="form-textarea" placeholder="Enter your coaching notes..." required></textarea> <textarea name="content" id="content" rows="6" class="form-textarea" placeholder="{{ _('Enter your coaching notes...') }}" required></textarea>
</div> </div>
{% if context_type != 'tryout' and context_type != 'match' %} {% if context_type != 'tryout' and context_type != 'match' %}
<div class="form-group"> <div class="form-group">
<label for="tryout_id">Link to Tryout (Optional)</label> <label for="tryout_id">{{ _('Link to Tryout (Optional)') }}</label>
<select name="tryout_id" id="tryout_id" class="form-select"> <select name="tryout_id" id="tryout_id" class="form-select">
<option value="">-- No tryout --</option> <option value="">{{ _('-- No tryout --') }}</option>
{% for t in tryouts %} {% for t in tryouts %}
<option value="{{ t.id }}">{{ t.title }} ({{ t.date.strftime('%Y-%m-%d') }})</option> <option value="{{ t.id }}">{{ t.title }} ({{ t.date.strftime('%Y-%m-%d') }})</option>
{% endfor %} {% endfor %}
@@ -72,9 +72,9 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="match_id">Link to Match (Optional)</label> <label for="match_id">{{ _('Link to Match (Optional)') }}</label>
<select name="match_id" id="match_id" class="form-select"> <select name="match_id" id="match_id" class="form-select">
<option value="">-- No match --</option> <option value="">{{ _('-- No match --') }}</option>
{% for m in matches %} {% for m in matches %}
<option value="{{ m.id }}">{{ m.title }} ({{ m.date.strftime('%Y-%m-%d') }})</option> <option value="{{ m.id }}">{{ m.title }} ({{ m.date.strftime('%Y-%m-%d') }})</option>
{% endfor %} {% endfor %}
@@ -82,9 +82,9 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="team_id">Link to Team (Optional)</label> <label for="team_id">{{ _('Link to Team (Optional)') }}</label>
<select name="team_id" id="team_id" class="form-select"> <select name="team_id" id="team_id" class="form-select">
<option value="">-- No team --</option> <option value="">{{ _('-- No team --') }}</option>
{% for team in teams %} {% for team in teams %}
<option value="{{ team.id }}">{{ team.name }}</option> <option value="{{ team.id }}">{{ team.name }}</option>
{% endfor %} {% endfor %}
@@ -94,7 +94,7 @@
{% if team_notes %} {% if team_notes %}
<div class="form-group"> <div class="form-group">
<label>Team Notes (Reference)</label> <label>{{ _('Team Notes (Reference)') }}</label>
<div class="team-notes-reference"> <div class="team-notes-reference">
{% for note in team_notes %} {% for note in team_notes %}
<div class="note-reference-item"> <div class="note-reference-item">
@@ -117,7 +117,7 @@
{% endif %} {% endif %}
" class="btn btn-secondary">Cancel</a> " class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Add Note <i class="fas fa-save"></i> {{ _('Add Note') }}
</button> </button>
</div> </div>
</form> </form>
+277
View File
@@ -0,0 +1,277 @@
{% extends "layouts/base.html" %}
{% block title %}Admin Panel{% endblock %}
{% block page_title %}Admin Panel{% endblock %}
{% block content %}
<!-- Stats Bar -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon bg-primary">
<i class="fas fa-users"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_users }}</h3>
<p>Total Users</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-success">
<i class="fas fa-user"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_players }}</h3>
<p>Players</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-warning">
<i class="fas fa-shield-alt"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_org_teams }}</h3>
<p>Org Teams</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-info">
<i class="fas fa-calendar-alt"></i>
</div>
<div class="stat-info">
<h3>{{ stats.active_tryouts }}</h3>
<p>Active Tryouts</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-secondary">
<i class="fas fa-clock"></i>
</div>
<div class="stat-info">
<h3>{{ stats.upcoming_tryouts }}</h3>
<p>Upcoming</p>
</div>
</div>
</div>
<div class="dashboard-grid">
<!-- Left column -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-toggle-{% if settings.tryouts_open %}on{% else %}off{% endif %}"></i> Tryouts Access</h3>
</div>
<div class="card-body">
<p>
Status:
<span class="badge badge-{% if settings.tryouts_open %}success{% else %}danger{% endif %}">
{% if settings.tryouts_open %}OPEN{% else %}CLOSED{% endif %}
</span>
</p>
<p class="text-muted" style="font-size: 0.85rem; margin: 8px 0;">
{% if settings.tryouts_open %}
Coaches and managers can create and modify tryouts.
{% else %}
Only admins can create or modify tryouts. Coaches and managers are locked out.
{% endif %}
</p>
<form method="POST" action="{{ url_for('admin.toggle_tryouts') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-{% if settings.tryouts_open %}warning{% else %}success{% endif %}">
{% if settings.tryouts_open %}Close Tryouts{% else %}Open Tryouts{% endif %}
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-calendar-check"></i> Season Management</h3>
</div>
<div class="card-body">
<p>
Season:
<span class="badge badge-{% if settings.season_active %}success{% else %}info{% endif %}">
{% if settings.season_active %}ACTIVE{% else %}INACTIVE{% endif %}
</span>
</p>
<p><strong>Name:</strong> {{ settings.season_name }}</p>
<p><strong>Start:</strong> {{ settings.season_start }}</p>
<p><strong>End:</strong> {{ settings.season_end }}</p>
{% if not settings.season_active %}
<hr style="margin: 12px 0;">
<form method="POST" action="{{ url_for('admin.start_season') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Season Name</label>
<input type="text" name="season_name" class="form-input" placeholder="e.g. Fall 2026" required>
</div>
<div class="form-group">
<label>Start Date</label>
<input type="date" name="season_start" class="form-input" required>
</div>
<button type="submit" class="btn btn-success">Begin Season</button>
</form>
{% else %}
<hr style="margin: 12px 0;">
<form method="POST" action="{{ url_for('admin.end_season') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>End Date</label>
<input type="date" name="season_end" class="form-input" required>
</div>
<button type="submit" class="btn btn-warning">End Season</button>
</form>
{% endif %}
</div>
</div>
<!-- Wipe Teams -->
<div class="card" style="border-left: 4px solid var(--danger);">
<div class="card-header">
<h3><i class="fas fa-trash-alt" style="color: var(--danger);"></i> Wipe Teams for New Season</h3>
</div>
<div class="card-body">
<p class="text-muted" style="font-size: 0.85rem; margin-bottom: 12px;">
This removes all players from organization teams and deletes all regular-season matches.
Team structures, coaches, and managers are preserved. A safety backup is created automatically.
</p>
<form method="POST" action="{{ url_for('admin.wipe_teams') }}" onsubmit="return confirmWipe()">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Type <strong>WIPE</strong> to confirm:</label>
<input type="text" name="confirm" class="form-input" placeholder="WIPE" autocomplete="off" required>
</div>
<button type="submit" class="btn btn-danger">Wipe Team Rosters</button>
</form>
</div>
</div>
<!-- Manual Backup -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-database"></i> Manual Backup</h3>
</div>
<div class="card-body">
<p class="text-muted" style="font-size: 0.85rem; margin-bottom: 12px;">
Creates a full PostgreSQL database dump (.sql file). Stored on the server.
</p>
<form method="POST" action="{{ url_for('admin.create_backup') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Notes (optional)</label>
<input type="text" name="notes" class="form-input" placeholder="What's this backup for?">
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Create Backup Now
</button>
</form>
</div>
</div>
<!-- Backup History -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-history"></i> Backup History & Restore</h3>
</div>
<div class="card-body">
{% if backups %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Filename</th>
<th>Size</th>
<th>Type</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for b in backups %}
<tr>
<td>{{ b.created_at.strftime('%Y-%m-%d %H:%M') if b.created_at else '—' }}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ b.filename }}">{{ b.filename }}</td>
<td>{{ (b.size_bytes / 1024)|round(1) }} KB</td>
<td><span class="badge badge-info">{{ b.backup_type }}</span></td>
<td style="white-space: nowrap;">
<a href="{{ url_for('admin.download_backup', backup_id=b.id) }}" class="btn btn-sm btn-primary" title="Download">
<i class="fas fa-download"></i>
</a>
<form method="POST" action="{{ url_for('admin.restore_backup', backup_id=b.id) }}" class="inline-form"
onsubmit="return confirm('Restore backup {{ b.filename }}? This will overwrite all current data. A safety backup will be made first.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-warning" title="Restore">
<i class="fas fa-undo"></i>
</button>
</form>
<form method="POST" action="{{ url_for('admin.delete_backup', backup_id=b.id) }}" class="inline-form"
onsubmit="return confirm('Delete backup {{ b.filename }}?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted" style="font-size: 0.85rem;">No backups yet. Create your first backup above.</p>
{% endif %}
</div>
</div>
</div>
<!-- Audit Log -->
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-clipboard-list"></i> Audit Log (Recent)</h3>
</div>
<div class="card-body">
{% if audit_logs %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>Action</th>
<th>Details</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{% for entry in audit_logs %}
<tr>
<td>{{ entry.created_at.strftime('%Y-%m-%d %H:%M') if entry.created_at else '—' }}</td>
<td>{{ entry.user.username if entry.user else 'System' }}</td>
<td><span class="badge badge-info">{{ entry.action }}</span></td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ entry.details or '—' }}</td>
<td>{{ entry.ip_address or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted" style="font-size: 0.85rem;">No audit log entries yet.</p>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function confirmWipe() {
var input = document.querySelector('input[name="confirm"]');
if (input.value.trim() !== 'WIPE') {
alert('You must type WIPE to confirm.');
return false;
}
return confirm('This will remove ALL players from organization teams and delete ALL regular-season matches. A safety backup will be created automatically. Are you ABSOLUTELY sure?');
}
</script>
{% endblock %}
+85
View File
@@ -0,0 +1,85 @@
{% 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 }}" data-mirror>
<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 %}
+166 -130
View File
@@ -1,25 +1,25 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% block title %}Calendar - TryoutPro{% endblock %} {% block title %}{{ _('Calendar') }} - UdeS team manager{% endblock %}
{% block page_title %}Calendar{% endblock %} {% block page_title %}{{ _('Calendar') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Calendar</span>{% endblock %} {% block breadcrumb %}<span class="breadcrumb">Home / Calendar</span>{% endblock %}
{% block content %} {% block content %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-calendar-alt"></i> Schedule</h3> <h3><i class="fas fa-calendar-alt"></i> {{ _('Schedule') }}</h3>
<div class="header-actions"> <div class="header-actions">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('dayGridMonth')"> <button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="dayGridMonth">
<i class="fas fa-calendar"></i> Month <i class="fas fa-calendar"></i> {{ _('Month') }}
</button> </button>
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridWeek')"> <button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridWeek">
<i class="fas fa-calendar-week"></i> Week <i class="fas fa-calendar-week"></i> {{ _('Week') }}
</button> </button>
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridDay')"> <button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridDay">
<i class="fas fa-calendar-day"></i> Day <i class="fas fa-calendar-day"></i> {{ _('Day') }}
</button> </button>
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('listMonth')"> <button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="listMonth">
<i class="fas fa-list"></i> List <i class="fas fa-list"></i> {{ _('List') }}
</button> </button>
</div> </div>
</div> </div>
@@ -31,26 +31,26 @@
<!-- Create Event Modal (for clicking empty days) --> <!-- Create Event Modal (for clicking empty days) -->
<div id="createEventModal" class="modal hidden"> <div id="createEventModal" class="modal hidden">
<div class="modal-backdrop" onclick="hideCreateEventModal()"></div> <div class="modal-backdrop" data-action="hide-create-event"></div>
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3><i class="fas fa-plus-circle"></i> Create New Event</h3> <h3><i class="fas fa-plus-circle"></i> {{ _('Create New Event') }}</h3>
<button class="modal-close" onclick="hideCreateEventModal()">&times;</button> <button class="modal-close" data-action="hide-create-event">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="mb-3"><strong>Date:</strong> <span id="createEventDate"></span></p> <p class="mb-3"><strong>{{ _('Date:') }}</strong> <span id="createEventDate"></span></p>
<input type="hidden" id="createEventDateInput"/> <input type="hidden" id="createEventDateInput"/>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5><i class="fas fa-futbol"></i> Schedule Tryout Match</h5> <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> <p class="text-muted small">{{ _('Add a scrim/match inside an existing tryout') }}</p>
<div class="form-inline"> <div class="form-inline">
<select id="createTryoutSelect" class="form-select" style="flex:1;"> <select id="createTryoutSelect" class="form-select" style="flex:1;">
<option value="">-- Select a tryout --</option> <option value="">{{ _('-- Select a tryout --') }}</option>
</select> </select>
<button class="btn btn-sm btn-primary ml-2" onclick="goToTryoutMatch()"> <button class="btn btn-sm btn-primary ml-2" data-action="go-tryout-match">
<i class="fas fa-arrow-right"></i> Go <i class="fas fa-arrow-right"></i> {{ _('Go') }}
</button> </button>
</div> </div>
</div> </div>
@@ -58,14 +58,14 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5><i class="fas fa-users"></i> Schedule Team Match</h5> <h5><i class="fas fa-users"></i> {{ _('Schedule Team Match') }}</h5>
<p class="text-muted small">Regular season match for an org team</p> <p class="text-muted small">{{ _('Regular season match for an org team') }}</p>
<div class="form-inline"> <div class="form-inline">
<select id="createTeamSelect" class="form-select" style="flex:1;"> <select id="createTeamSelect" class="form-select" style="flex:1;">
<option value="">-- Select a team --</option> <option value="">{{ _('-- Select a team --') }}</option>
</select> </select>
<button class="btn btn-sm btn-success ml-2" onclick="goToTeamMatch()"> <button class="btn btn-sm btn-success ml-2" data-action="go-team-match">
<i class="fas fa-arrow-right"></i> Go <i class="fas fa-arrow-right"></i> {{ _('Go') }}
</button> </button>
</div> </div>
</div> </div>
@@ -73,10 +73,10 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5><i class="fas fa-calendar-plus"></i> Create New Tryout</h5> <h5><i class="fas fa-calendar-plus"></i> {{ _('Create New Tryout') }}</h5>
<p class="text-muted small">Create a brand new tryout event</p> <p class="text-muted small">{{ _('Create a brand new tryout event') }}</p>
<button class="btn btn-sm btn-info" onclick="goToCreateTryout()"> <button class="btn btn-sm btn-info" data-action="go-create-tryout">
<i class="fas fa-plus"></i> Create Tryout <i class="fas fa-plus"></i> {{ _('Create Tryout') }}
</button> </button>
</div> </div>
</div> </div>
@@ -86,26 +86,26 @@
<!-- Event Details Modal --> <!-- Event Details Modal -->
<div id="eventModal" class="modal hidden"> <div id="eventModal" class="modal hidden">
<div class="modal-backdrop" onclick="hideEventModal()"></div> <div class="modal-backdrop" data-action="hide-event-modal"></div>
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 id="modalTitle">Event Details</h3> <h3 id="modalTitle">{{ _('Event Details') }}</h3>
<button class="modal-close" onclick="hideEventModal()">&times;</button> <button class="modal-close" data-action="hide-event-modal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div id="modalContent"></div> <div id="modalContent"></div>
<div id="modalPresenceToggle" class="form-actions mt-3" style="display: none; justify-content: center;"> <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> <button class="btn btn-sm btn-outline" id="calPresenceBtn">{{ _('Confirm') }}</button>
</div> </div>
<div id="modalActions" class="form-actions mt-3" style="display: none;"> <div id="modalActions" class="form-actions mt-3" style="display: none;">
<button class="btn btn-sm btn-danger" id="deleteMatchBtn" style="display: none;"> <button class="btn btn-sm btn-danger" id="deleteMatchBtn" style="display: none;">
<i class="fas fa-trash"></i> Delete Match <i class="fas fa-trash"></i> {{ _('Delete Match') }}
</button> </button>
<button class="btn btn-sm btn-primary" id="editMatchBtn" style="display: none;"> <button class="btn btn-sm btn-primary" id="editMatchBtn" style="display: none;">
<i class="fas fa-edit"></i> Edit Match <i class="fas fa-edit"></i> {{ _('Edit Match') }}
</button> </button>
<button class="btn btn-sm btn-primary" id="viewTryoutBtn" style="display: none;"> <button class="btn btn-sm btn-primary" id="viewTryoutBtn" style="display: none;">
<i class="fas fa-eye"></i> View Tryout <i class="fas fa-eye"></i> {{ _('View Tryout') }}
</button> </button>
</div> </div>
</div> </div>
@@ -114,9 +114,20 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<link href="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.css" rel="stylesheet"> {# The stylesheet that used to sit here — index.global.min.css — does not
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js"></script> exist. FullCalendar 6 bundles its styles into the JS, and that file is not
<script> 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 %}; var canScheduleMatches = {% if current_user.can_schedule_matches() %}true{% else %}false{% endif %};
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@@ -205,14 +216,16 @@ function fetchTryoutOptions() {
fetch('/matches/api/manageable-tryouts') fetch('/matches/api/manageable-tryouts')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .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'); var sel = document.getElementById('createTryoutSelect');
sel.innerHTML = '<option value="">-- Select a tryout --</option>'; sel.replaceChildren(new Option('-- Select a tryout --', ''));
var today = new Date().toISOString().split('T')[0]; var today = new Date().toISOString().split('T')[0];
data.forEach(function(t) { data.forEach(function(t) {
// Only show tryouts that haven't ended // Only show tryouts that haven't ended
var tryoutEndDate = t.end_date || t.date; var tryoutEndDate = t.end_date || t.date;
if (tryoutEndDate >= today) { if (tryoutEndDate >= today) {
sel.innerHTML += '<option value="' + t.id + '">' + t.title + ' (' + t.date + ')</option>'; sel.appendChild(new Option(t.title + ' (' + t.date + ')', t.id));
} }
}); });
}) })
@@ -224,109 +237,130 @@ function fetchTeamOptions() {
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
var sel = document.getElementById('createTeamSelect'); var sel = document.getElementById('createTeamSelect');
sel.innerHTML = '<option value="">-- Select a team --</option>'; sel.replaceChildren(new Option('-- Select a team --', ''));
data.forEach(function(t) { data.forEach(function(t) {
sel.innerHTML += '<option value="' + t.id + '">' + t.name + '</option>'; sel.appendChild(new Option(t.name, t.id));
}); });
}) })
.catch(function() {}); .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) { function showEventModal(event) {
var props = event.extendedProps; var props = event.extendedProps;
var title = event.title; var title = event.title;
var type = props.type; var type = props.type;
var date = event.start ? event.start.toDateString() : ''; var date = event.start ? event.start.toDateString() : '';
var content = '<div class="detail-grid">'; // 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');
if (type === 'one_on_one') { var typeLabel = props.match_type === 'team_vs_team' ? 'Team Match'
// One on One session modal : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim');
var startTime = props.start_time ? formatTime(props.start_time) : null; var typeBadgeClass = (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player')
var endTime = props.end_time ? formatTime(props.end_time) : null; ? 'success' : 'warning';
var timeRange = startTime ? startTime + (endTime ? ' - ' + endTime : '') : 'TBD';
content += '<div class="detail-item"><span class="detail-label">Type</span><span class="detail-value">'; grid.appendChild(detailItem('Type', makeEl('span', 'badge badge-' + typeBadgeClass, typeLabel)));
content += '<span class="badge badge-info">One on One Session</span>'; grid.appendChild(detailItem('Title', document.createTextNode(title)));
content += '</span></div>'; grid.appendChild(detailItem('Date', document.createTextNode(date)));
content += '<div class="detail-item"><span class="detail-label">Title</span><span class="detail-value">' + title + '</span></div>'; grid.appendChild(detailItem('Location', document.createTextNode(props.location || 'TBD')));
content += '<div class="detail-item"><span class="detail-label">Date</span><span class="detail-value">' + date + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Time</span><span class="detail-value">' + timeRange + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Location</span><span class="detail-value">' + (props.location || 'TBD') + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Participants</span><span class="detail-value">' + (props.participants || '') + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Status</span><span class="detail-value">';
content += '<span class="badge badge-success">Approved</span>';
content += '</span></div>';
if (props.description) {
content += '<div class="detail-item full-width"><span class="detail-label">Discussion Points</span><span class="detail-value">' + props.description + '</span></div>';
}
} else {
// Match modal (existing behavior)
content += '<div class="detail-item"><span class="detail-label">Type</span><span class="detail-value">';
content += '<span class="badge badge-' + (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player' ? 'success' : 'warning') + '">';
content += (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim')) + '</span>';
content += '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Title</span><span class="detail-value">' + title + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Date</span><span class="detail-value">' + date + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Location</span><span class="detail-value">' + (props.location || 'TBD') + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Status</span><span class="detail-value">';
content += '<span class="badge badge-' + (props.status || 'scheduled') + '">' + (props.status || 'scheduled') + '</span>';
content += '</span></div>';
// Add team separation for matches var status = props.status || 'scheduled';
if (props.participants) { grid.appendChild(detailItem('Status', makeEl('span', 'badge badge-' + status, status)));
content += '<div class="detail-item full-width"><span class="detail-label">Teams</span><span class="detail-value">';
if (props.match_type === 'team_vs_team') {
var teams = props.participants.split(' vs ');
if (teams.length >= 2) {
content += '<div class="match-teams">';
content += '<div class="match-team"><span class="team-name">' + teams[0] + '</span></div>';
content += '<div class="match-vs">vs</div>';
content += '<div class="match-team"><span class="team-name">' + teams[1] + '</span></div>';
content += '</div>';
} else {
content += props.participants;
}
} else if (props.match_type === 'player_vs_player') {
var parts = props.participants.split(' vs ');
if (parts.length >= 2) {
content += '<div class="match-teams">';
content += '<div class="match-team"><span class="team-name">Team 1</span><ul class="team-players-list"><li>' + parts[0].split(', ').join('</li><li>') + '</li></ul></div>';
content += '<div class="match-vs">vs</div>';
content += '<div class="match-team"><span class="team-name">Team 2</span><ul class="team-players-list"><li>' + parts[1].split(', ').join('</li><li>') + '</li></ul></div>';
content += '</div>';
} else {
content += props.participants;
}
} else {
content += props.participants;
}
content += '</span></div>';
}
if (props.description) { if (type === 'match' && props.participants) {
content += '<div class="detail-item full-width"><span class="detail-label">Description</span><span class="detail-value">' + props.description + '</span></div>'; grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
}
} }
content += '</div>'; if (props.description) {
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
}
document.getElementById('modalTitle').textContent = (type === 'one_on_one' ? 'One on One Session' : 'Match Details'); var modalContent = document.getElementById('modalTitle');
document.getElementById('modalContent').innerHTML = content; modalContent.textContent = 'Match Details';
var target = document.getElementById('modalContent');
target.textContent = '';
target.appendChild(grid);
// Reset buttons // Reset buttons
document.getElementById('deleteMatchBtn').style.display = 'none'; document.getElementById('deleteMatchBtn').style.display = 'none';
document.getElementById('editMatchBtn').style.display = 'none'; document.getElementById('editMatchBtn').style.display = 'none';
document.getElementById('viewTryoutBtn').style.display = 'none'; document.getElementById('viewTryoutBtn').style.display = 'none';
// Hide match actions and presence toggle for one-on-one events
if (type === 'one_on_one') {
document.getElementById('modalActions').style.display = 'none';
document.getElementById('modalPresenceToggle').style.display = 'none';
document.getElementById('eventModal').classList.remove('hidden');
return;
}
// Show action buttons for matches (coaches and above) // Show action buttons for matches (coaches and above)
if (type === 'match' && canScheduleMatches) { if (type === 'match' && canScheduleMatches) {
document.getElementById('modalActions').style.display = 'flex'; document.getElementById('modalActions').style.display = 'flex';
@@ -362,17 +396,6 @@ function showEventModal(event) {
document.getElementById('eventModal').classList.remove('hidden'); document.getElementById('eventModal').classList.remove('hidden');
} }
function formatTime(timeStr) {
if (!timeStr) return null;
var parts = timeStr.split(':');
var h = parseInt(parts[0]);
var m = parts[1];
var ampm = h >= 12 ? 'PM' : 'AM';
var displayHour = h % 12;
if (displayHour === 0) displayHour = 12;
return displayHour + ':' + m + ' ' + ampm;
}
function deleteCalendarMatch(matchId) { function deleteCalendarMatch(matchId) {
fetch('/matches/' + matchId + '/delete', { fetch('/matches/' + matchId + '/delete', {
method: 'POST', method: 'POST',
@@ -422,5 +445,18 @@ function toggleCalendarPresence(matchId, participantId, btn) {
function hideEventModal() { function hideEventModal() {
document.getElementById('eventModal').classList.add('hidden'); 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> </script>
{% endblock %} {% endblock %}
+42 -13
View File
@@ -1,22 +1,25 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% block title %}Manage Availability - TryoutPro{% endblock %} {% block title %}{{ _('Manage Availability') }} - UdeS team manager{% endblock %}
{% block page_title %}Manage Availability{% endblock %} {% block page_title %}{{ _('Manage Availability') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Coach Availability</span>{% endblock %} {% block breadcrumb %}<span class="breadcrumb">Home / Coach Availability</span>{% endblock %}
{% block content %} {% block content %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-clock"></i> Set Your Weekly Availability</h3> <h3><i class="fas fa-clock"></i> {{ _('Set Your Weekly Availability') }}</h3>
<p class="text-muted small">Select time slots when you're available for One on One sessions</p> <p class="text-muted small">{{ _('Select time slots when you\'re available for One on One sessions') }}</p>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="availability-grid" id="availability-grid"> <div class="availability-grid" id="availability-grid">
<p class="text-muted">Loading availability grid...</p> <p class="text-muted">{{ _('Loading availability grid...') }}</p>
</div> </div>
<div class="form-actions mt-4"> <div class="form-actions mt-4">
<button type="button" class="btn btn-secondary" onclick="clearAllAvailability()"> <button type="button" class="btn btn-primary" data-action="save-availability">
<i class="fas fa-trash"></i> Clear All <i class="fas fa-save"></i> {{ _('Save Availability') }}
</button>
<button type="button" class="btn btn-secondary" data-action="clear-availability">
<i class="fas fa-trash"></i> {{ _('Clear All') }}
</button> </button>
</div> </div>
</div> </div>
@@ -88,7 +91,7 @@
} }
} }
</style> </style>
<script> <script nonce="{{ csp_nonce }}">
// Time slots from 8:00 AM to 10:00 PM (30-minute intervals) // Time slots from 8:00 AM to 10:00 PM (30-minute intervals)
const TIME_SLOTS = []; const TIME_SLOTS = [];
for (let h = 8; h <= 22; h++) { for (let h = 8; h <= 22; h++) {
@@ -134,7 +137,7 @@ function renderGrid() {
TIME_SLOTS.forEach(slot => { TIME_SLOTS.forEach(slot => {
const isSelected = selectedSlots[dayIndex] && selectedSlots[dayIndex].includes(slot.time); const isSelected = selectedSlots[dayIndex] && selectedSlots[dayIndex].includes(slot.time);
const cssClass = isSelected ? 'time-slot selected' : 'time-slot'; const cssClass = isSelected ? 'time-slot selected' : 'time-slot';
html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '" onclick="toggleSlot(' + dayIndex + ', \'' + slot.time + '\', this)">' + slot.display + '</div>'; html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '" data-action="toggle-slot">' + slot.display + '</div>';
}); });
html += '</div>'; html += '</div>';
@@ -143,7 +146,9 @@ function renderGrid() {
grid.innerHTML = html; grid.innerHTML = html;
} }
function toggleSlot(dayOfWeek, timeStr, element) { function toggleSlot(element) {
const dayOfWeek = Number(element.getAttribute('data-day'));
const timeStr = element.getAttribute('data-time');
if (!selectedSlots[dayOfWeek]) { if (!selectedSlots[dayOfWeek]) {
selectedSlots[dayOfWeek] = []; selectedSlots[dayOfWeek] = [];
} }
@@ -168,13 +173,18 @@ function saveAvailability() {
fetch('{{ url_for("users.manage_coach_availability") }}', { fetch('{{ url_for("users.manage_coach_availability") }}', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ slots: slots }) body: JSON.stringify({ slots: slots })
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
flash('Availability saved!', 'success'); flash('Availability saved!', 'success');
} else {
flash(data.error || 'Error saving availability.', 'danger');
} }
}) })
.catch(function(error) { .catch(function(error) {
@@ -188,7 +198,10 @@ function clearAllAvailability() {
return; return;
} }
fetch('{{ url_for("users.clear_coach_availability") }}', { method: 'POST' }) fetch('{{ url_for("users.clear_coach_availability") }}', {
method: 'POST',
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
})
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
@@ -203,7 +216,14 @@ function flash(message, type) {
const flashContainer = document.querySelector('.flash-messages'); const flashContainer = document.querySelector('.flash-messages');
const alert = document.createElement('div'); const alert = document.createElement('div');
alert.className = 'alert alert-' + type + ' alert-dismissible'; alert.className = 'alert alert-' + type + ' alert-dismissible';
alert.innerHTML = '<span>' + message + '</span><button type="button" class="alert-close" onclick="this.parentElement.remove()">&times;</button>'; const text = document.createElement('span');
text.textContent = message;
const close = document.createElement('button');
close.type = 'button';
close.className = 'alert-close';
close.dataset.action = 'remove-element';
close.textContent = '×';
alert.append(text, close);
flashContainer.appendChild(alert); flashContainer.appendChild(alert);
} }
@@ -215,5 +235,14 @@ document.addEventListener('click', function(e) {
saveTimeout = setTimeout(saveAvailability, 1000); saveTimeout = setTimeout(saveAvailability, 1000);
} }
}); });
// 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({
'save-availability': saveAvailability,
'clear-availability': clearAllAvailability,
'toggle-slot': toggleSlot,
});
</script> </script>
{% endblock %} {% endblock %}
+37 -27
View File
@@ -1,12 +1,12 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% block title %}Contracts - TryoutPro{% endblock %} {% block title %}{{ _('Contracts') }} - UdeS team manager{% endblock %}
{% block page_title %}Contracts{% endblock %} {% block page_title %}{{ _('Contracts') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Contracts</span>{% endblock %} {% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Contracts</span>{% endblock %}
{% block header_actions %} {% block header_actions %}
{% if current_user.role in ['admin', 'manager', 'coach'] %} {% if current_user.role in ['admin', 'manager', 'coach'] %}
<a href="{{ url_for('users.upload_contract') }}" class="btn btn-primary"> <a href="{{ url_for('users.upload_contract') }}" class="btn btn-primary">
<i class="fas fa-upload"></i> Upload Contract <i class="fas fa-upload"></i> {{ _('Upload Contract') }}
</a> </a>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -14,7 +14,7 @@
{% block content %} {% block content %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-file-contract"></i> Contract Dropbox</h3> <h3><i class="fas fa-file-contract"></i> {{ _('Contract Dropbox') }}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if contracts %} {% if contracts %}
@@ -22,12 +22,12 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Player</th> <th>{{ _('Player') }}</th>
<th>Team</th> <th>{{ _('Team') }}</th>
<th>Contract</th> <th>{{ _('Contract') }}</th>
<th>Status</th> <th>{{ _('Status') }}</th>
<th>Uploaded</th> <th>{{ _('Uploaded') }}</th>
<th>Actions</th> <th>{{ _('Actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -46,17 +46,17 @@
<td>{{ contract.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td> <td>{{ contract.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td> <td>
<div class="btn-group"> <div class="btn-group">
<a href="{{ url_for('users.download_contract', contract_id=contract.id) }}" class="btn btn-sm btn-primary" title="Download"> <a href="{{ url_for('users.download_contract', contract_id=contract.id) }}" class="btn btn-sm btn-primary" title="{{ _('Download') }}">
<i class="fas fa-download"></i> Download <i class="fas fa-download"></i> {{ _('Download') }}
</a> </a>
{% if contract.signed_file_path %} {% if contract.signed_file_path %}
<a href="{{ url_for('users.download_signed_contract', contract_id=contract.id) }}" class="btn btn-sm btn-success" title="Download Signed"> <a href="{{ url_for('users.download_signed_contract', contract_id=contract.id) }}" class="btn btn-sm btn-success" title="{{ _('Download Signed') }}">
<i class="fas fa-file-signature"></i> Signed <i class="fas fa-file-signature"></i> {{ _('Signed') }}
</a> </a>
{% endif %} {% endif %}
{% if current_user.role == 'player' and contract.status == 'pending' %} {% if current_user.role == 'player' and contract.status == 'pending' %}
<button class="btn btn-sm btn-warning" onclick="showUploadSignedForm({{ contract.id }})" title="Upload Signed Contract"> <button class="btn btn-sm btn-warning" data-action="show-upload-signed" data-contract-id="{{ contract.id }}" title="{{ _('Upload Signed Contract') }}">
<i class="fas fa-upload"></i> Return Signed <i class="fas fa-upload"></i> {{ _('Return Signed') }}
</button> </button>
{% endif %} {% endif %}
</div> </div>
@@ -69,11 +69,11 @@
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<i class="fas fa-file-contract"></i> <i class="fas fa-file-contract"></i>
<h4>No contracts found</h4> <h4>{{ _('No contracts found') }}</h4>
{% if current_user.role == 'player' %} {% if current_user.role == 'player' %}
<p>No contracts have been uploaded for you yet. Contact your coach or manager.</p> <p>{{ _('No contracts have been uploaded for you yet. Contact your coach or manager.') }}</p>
{% else %} {% else %}
<p>No contracts have been uploaded yet. Upload a contract using the button above.</p> <p>{{ _('No contracts have been uploaded yet. Upload a contract using the button above.') }}</p>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -83,23 +83,23 @@
{% if current_user.role == 'player' %} {% if current_user.role == 'player' %}
<!-- Upload Signed Contract Modal --> <!-- Upload Signed Contract Modal -->
<div id="uploadSignedModal" class="modal hidden"> <div id="uploadSignedModal" class="modal hidden">
<div class="modal-backdrop" onclick="hideUploadSignedForm()"></div> <div class="modal-backdrop" data-action="hide-upload-signed"></div>
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>Upload Signed Contract</h3> <h3>{{ _('Upload Signed Contract') }}</h3>
<button class="modal-close" onclick="hideUploadSignedForm()">&times;</button> <button class="modal-close" data-action="hide-upload-signed">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="uploadSignedForm" method="POST" enctype="multipart/form-data" class="form"> <form id="uploadSignedForm" method="POST" enctype="multipart/form-data" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
<label for="signed_file">Select Signed Contract File</label> <label for="signed_file">{{ _('Select Signed Contract File') }}</label>
<input type="file" id="signed_file" name="signed_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required> <input type="file" id="signed_file" name="signed_file" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required>
<small class="form-text text-muted">Accepted formats: PDF, DOC, DOCX, JPG, PNG</small> <small class="form-text text-muted">{{ _('Accepted formats: PDF, DOC, DOCX, JPG, PNG') }}</small>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="hideUploadSignedForm()">Cancel</button> <button type="button" class="btn btn-secondary" data-action="hide-upload-signed">{{ _('Cancel') }}</button>
<button type="submit" class="btn btn-primary">Upload Signed Contract</button> <button type="submit" class="btn btn-primary">{{ _('Upload Signed Contract') }}</button>
</div> </div>
</form> </form>
</div> </div>
@@ -107,7 +107,7 @@
</div> </div>
{% endif %} {% endif %}
<script> <script nonce="{{ csp_nonce }}">
function showUploadSignedForm(contractId) { function showUploadSignedForm(contractId) {
document.getElementById('uploadSignedForm').action = '/users/contracts/' + contractId + '/upload_signed'; document.getElementById('uploadSignedForm').action = '/users/contracts/' + contractId + '/upload_signed';
document.getElementById('uploadSignedModal').classList.remove('hidden'); document.getElementById('uploadSignedModal').classList.remove('hidden');
@@ -116,5 +116,15 @@ function showUploadSignedForm(contractId) {
function hideUploadSignedForm() { function hideUploadSignedForm() {
document.getElementById('uploadSignedModal').classList.add('hidden'); document.getElementById('uploadSignedModal').classList.add('hidden');
} }
// 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-upload-signed': function (element) {
showUploadSignedForm(element.getAttribute('data-contract-id'));
},
'hide-upload-signed': hideUploadSignedForm,
});
</script> </script>
{% endblock %} {% endblock %}
+14 -14
View File
@@ -1,6 +1,6 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% block title %}Create User - TryoutPro{% endblock %} {% block title %}{{ _('Create User') }} - UdeS team manager{% endblock %}
{% block page_title %}Create User{% endblock %} {% block page_title %}{{ _('Create User') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.list_users') }}">Users</a> / Create</span>{% endblock %} {% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.list_users') }}">Users</a> / Create</span>{% endblock %}
{% block content %} {% block content %}
@@ -10,25 +10,25 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row"> <div class="form-row">
<div class="form-group col-6"> <div class="form-group col-6">
<label for="full_name">Full Name</label> <label for="full_name">{{ _('Full Name') }}</label>
<input type="text" id="full_name" name="full_name" placeholder="Enter full name" required> <input type="text" id="full_name" name="full_name" placeholder="{{ _('Enter full name') }}" required>
</div> </div>
<div class="form-group col-6"> <div class="form-group col-6">
<label for="username">Username</label> <label for="username">{{ _('Username') }}</label>
<input type="text" id="username" name="username" placeholder="Choose username" required> <input type="text" id="username" name="username" placeholder="{{ _('Choose username') }}" required>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-6"> <div class="form-group col-6">
<label for="email">Email</label> <label for="email">{{ _('Email') }}</label>
<input type="email" id="email" name="email" placeholder="Enter email" required> <input type="email" id="email" name="email" placeholder="{{ _('Enter email') }}" required>
</div> </div>
<div class="form-group col-3"> <div class="form-group col-3">
<label for="phone">Phone</label> <label for="phone">{{ _('Phone') }}</label>
<input type="tel" id="phone" name="phone" placeholder="Phone number"> <input type="tel" id="phone" name="phone" placeholder="{{ _('Phone number') }}">
</div> </div>
<div class="form-group col-3"> <div class="form-group col-3">
<label for="role">Role</label> <label for="role">{{ _('Role') }}</label>
<select id="role" name="role" required> <select id="role" name="role" required>
{% for r in roles %} {% for r in roles %}
<option value="{{ r }}">{{ r | capitalize }}</option> <option value="{{ r }}">{{ r | capitalize }}</option>
@@ -38,13 +38,13 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-6"> <div class="form-group col-6">
<label for="password">Password</label> <label for="password">{{ _('Password') }}</label>
<input type="password" id="password" name="password" placeholder="Set password" required minlength="6"> <input type="password" id="password" name="password" placeholder="{{ _('Set password') }}" required minlength="6">
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<a href="{{ url_for('users.list_users') }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for('users.list_users') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Create User</button> <button type="submit" class="btn btn-primary">{{ _('Create User') }}</button>
</div> </div>
</form> </form>
</div> </div>

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